Skip to content

Commit bcf84cc

Browse files
fix(service): normalize user agent for sticky session hashes
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 13b72f6 commit bcf84cc

3 files changed

Lines changed: 91 additions & 1 deletion

File tree

backend/internal/service/gateway_request.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"encoding/json"
66
"fmt"
77
"math"
8+
"regexp"
9+
"sort"
810
"strings"
911
"unsafe"
1012

@@ -34,6 +36,9 @@ var (
3436
patternEmptyTextSpaced = []byte(`"text": ""`)
3537
patternEmptyTextSp1 = []byte(`"text" : ""`)
3638
patternEmptyTextSp2 = []byte(`"text" :""`)
39+
40+
sessionUserAgentProductPattern = regexp.MustCompile(`([A-Za-z0-9._-]+)/[A-Za-z0-9._-]+`)
41+
sessionUserAgentVersionPattern = regexp.MustCompile(`\bv?\d+(?:\.\d+){1,3}\b`)
3742
)
3843

3944
// SessionContext 粘性会话上下文,用于区分不同来源的请求。
@@ -75,6 +80,49 @@ type ParsedRequest struct {
7580
OnUpstreamAccepted func()
7681
}
7782

83+
// NormalizeSessionUserAgent reduces UA noise for sticky-session and digest hashing.
84+
// It preserves the set of product names from Product/Version tokens while
85+
// discarding version-only changes and incidental comments.
86+
func NormalizeSessionUserAgent(raw string) string {
87+
raw = strings.TrimSpace(raw)
88+
if raw == "" {
89+
return ""
90+
}
91+
92+
matches := sessionUserAgentProductPattern.FindAllStringSubmatch(raw, -1)
93+
if len(matches) == 0 {
94+
return normalizeSessionUserAgentFallback(raw)
95+
}
96+
97+
products := make([]string, 0, len(matches))
98+
seen := make(map[string]struct{}, len(matches))
99+
for _, match := range matches {
100+
if len(match) < 2 {
101+
continue
102+
}
103+
product := strings.ToLower(strings.TrimSpace(match[1]))
104+
if product == "" {
105+
continue
106+
}
107+
if _, exists := seen[product]; exists {
108+
continue
109+
}
110+
seen[product] = struct{}{}
111+
products = append(products, product)
112+
}
113+
if len(products) == 0 {
114+
return normalizeSessionUserAgentFallback(raw)
115+
}
116+
sort.Strings(products)
117+
return strings.Join(products, "+")
118+
}
119+
120+
func normalizeSessionUserAgentFallback(raw string) string {
121+
normalized := strings.ToLower(strings.Join(strings.Fields(raw), " "))
122+
normalized = sessionUserAgentVersionPattern.ReplaceAllString(normalized, "")
123+
return strings.Join(strings.Fields(normalized), " ")
124+
}
125+
78126
// ParseGatewayRequest 解析网关请求体并返回结构化结果。
79127
// protocol 指定请求协议格式(domain.PlatformAnthropic / domain.PlatformGemini),
80128
// 不同协议使用不同的 system/messages 字段名。

backend/internal/service/gateway_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ func (s *GatewayService) GenerateSessionHash(parsed *ParsedRequest) string {
658658
if parsed.SessionContext != nil {
659659
_, _ = combined.WriteString(parsed.SessionContext.ClientIP)
660660
_, _ = combined.WriteString(":")
661-
_, _ = combined.WriteString(parsed.SessionContext.UserAgent)
661+
_, _ = combined.WriteString(NormalizeSessionUserAgent(parsed.SessionContext.UserAgent))
662662
_, _ = combined.WriteString(":")
663663
_, _ = combined.WriteString(strconv.FormatInt(parsed.SessionContext.APIKeyID, 10))
664664
_, _ = combined.WriteString("|")

backend/internal/service/generate_session_hash_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,48 @@ func TestGenerateSessionHash_SessionContext_UADifference(t *testing.T) {
504504
require.NotEqual(t, h1, h2, "different User-Agent should produce different hash")
505505
}
506506

507+
func TestGenerateSessionHash_SessionContext_UAVersionNoiseIgnored(t *testing.T) {
508+
svc := &GatewayService{}
509+
510+
base := func(ua string) *ParsedRequest {
511+
return &ParsedRequest{
512+
Messages: []any{
513+
map[string]any{"role": "user", "content": "test"},
514+
},
515+
SessionContext: &SessionContext{
516+
ClientIP: "1.1.1.1",
517+
UserAgent: ua,
518+
APIKeyID: 1,
519+
},
520+
}
521+
}
522+
523+
h1 := svc.GenerateSessionHash(base("Mozilla/5.0 codex_cli_rs/0.1.0"))
524+
h2 := svc.GenerateSessionHash(base("Mozilla/5.0 codex_cli_rs/0.1.1"))
525+
require.Equal(t, h1, h2, "version-only User-Agent changes should not perturb the sticky session hash")
526+
}
527+
528+
func TestGenerateSessionHash_SessionContext_FreeformUAVersionNoiseIgnored(t *testing.T) {
529+
svc := &GatewayService{}
530+
531+
base := func(ua string) *ParsedRequest {
532+
return &ParsedRequest{
533+
Messages: []any{
534+
map[string]any{"role": "user", "content": "test"},
535+
},
536+
SessionContext: &SessionContext{
537+
ClientIP: "1.1.1.1",
538+
UserAgent: ua,
539+
APIKeyID: 1,
540+
},
541+
}
542+
}
543+
544+
h1 := svc.GenerateSessionHash(base("Codex CLI 0.1.0"))
545+
h2 := svc.GenerateSessionHash(base("Codex CLI 0.1.1"))
546+
require.Equal(t, h1, h2, "free-form version-only User-Agent changes should not perturb the sticky session hash")
547+
}
548+
507549
func TestGenerateSessionHash_SessionContext_APIKeyIDDifference(t *testing.T) {
508550
svc := &GatewayService{}
509551

0 commit comments

Comments
 (0)