File size: 9,426 Bytes
6d60378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
// ids.go 会话头族 ID 的解析与生成(issue #35 后台聚合)。
//
// 官方 CodeBuddy CLI 出站头族(X-Conversation-ID / X-Conversation-Request-ID /
// X-Request-ID / X-B3-*),后台按 X-Conversation-Request-ID(对话轮)聚合请求;
// 本文件提供 conversationId 提取、消息级 32 hex messageID、以及"同一会话键
// 稳定复用"的 conversationRequestID 惰性缓存,供 handler 轮转循环外生成、循环内
// 复用(换号/重试/降级全部同 ID → 后台不再碎片化)。
package session

import (
	cryptorand "crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"math/rand/v2"
	"strings"
)

// ResolveConversationID 从请求体提取会话头族的 conversationId(snake/camel 双形态,
// 复用 ExtractKey 的识别顺序:metadata 优先、snake 优先于 camel)。
// 与 ExtractKey 的差异:**只认 conversationId,绝不回落 user_id**——X-Conversation-ID
// 语义是"对话 ID",user_id 回落会污染后台按对话聚合的判据。
// 缺失返回 ""(不伪造:透传客户端原值优先,客户端没给就不发)。
func ResolveConversationID(body []byte) string {
	if len(body) == 0 {
		return ""
	}
	var obj map[string]any
	if err := json.Unmarshal(body, &obj); err != nil {
		return ""
	}
	if meta, ok := obj["metadata"].(map[string]any); ok {
		if v := strOrEmpty(meta["conversation_id"]); v != "" {
			return v
		}
		if v := strOrEmpty(meta["conversationId"]); v != "" {
			return v
		}
	}
	if v := strOrEmpty(obj["conversation_id"]); v != "" {
		return v
	}
	return strOrEmpty(obj["conversationId"])
}

// NewMessageID 生成消息级 ID:32 位 hex(UUID v4 去横线的长度形态),对齐官方
// X-Request-ID / X-Conversation-Message-ID。crypto/rand 失败(理论上不可能)时回落
// math/rand/v2 双 uint64 拼 32 hex——恒 32 hex、恒合法,可安全用作 B3 TraceId。
func NewMessageID() string {
	b := make([]byte, 16)
	if _, err := cryptorand.Read(b); err == nil {
		return hex.EncodeToString(b)
	}
	// 熵源故障的极端兜底:仍保证 32 hex(fallbackID 不 panic、不空串)。
	return fmt.Sprintf("%016x%016x", uint64(rand.Uint64())|1, rand.Uint64())
}

// deriveSalt 进程启动随机盐:对所有稳定聚合 ID 的纯派生统一加盐,使派生值无法
// 按外部可控的键内容(会话键/轮级键)被预计算;重启换新(重启时旧对话轮/会话已
// 结束,不构成断档)。会话级(RequestIDForKey)与轮级(TurnRequestID)共用同一盐。
var deriveSalt = NewMessageID()

// RequestIDForKey 返回会话键的稳定 conversationRequestID:sha256(盐|键) 前 16 字节
// 的 hex,纯派生(无缓存、无 TTL、内存不随键数增长——此前 sync.Map 惰性缓存随
// 会话键数量无上限增长,纯派生天然有界)。
//   - 同 key:进程内恒派生同值(一次 user send/同会话多轮聚合);
//   - 异 key:各自独立,互不相同;
//   - 空 key:每次生成新值(无会话则无"会话内稳定"语义——调用方应在请求级
//     捕获复用,handler 在轮转循环外取一次即天然共享)。
//
// 返回值恒为 32 hex(NewMessageID 形态),可直接用作 B3 TraceId(16/32 hex 合法)。
func RequestIDForKey(key string) string {
	if key == "" {
		return NewMessageID()
	}
	sum := sha256.Sum256([]byte(deriveSalt + "|" + key))
	return hex.EncodeToString(sum[:16])
}

// TurnKey 派生「对话轮级」聚合键:body 里**最后一条** role=="user" 消息的
// 「序号 + 文本」。
//
// 为什么需要它:无会话键的客户端(OpenAI 兼容协议——dsh / Codex / Cherry Studio
// 等的请求体里既无 conversationId 也无 metadata 键)会让 ExtractKey 恒返回空串,
// 会话头族的聚合主键便只能逐请求新生成,agent 多轮在上游用量明细里仍是一条请求
// 一条记录。本函数给这类客户端一个**不依赖客户端配合**的轮级键:一次用户发送内的
// 所有上游调用(tool call 多轮 / 换号重试 / 降级重发)body 里最后一条 user 消息
// 恒定 → 同键;用户发下一条消息 → 换键。
//
// 为什么不取第一条 user 消息:首条在整个会话内不变,会把一次会话的所有轮并进
// 同一个聚合键(跨对话轮混并)。取最后一条才对齐官方 X-Conversation-Request-ID
// 的「对话轮」语义。序号一并入键:两次不同轮里内容相同的提问("继续")不会被并成
// 一轮。
//
// 无 body / 无 messages / 无 user 消息 / 该消息无文本 → ""(调用方回落请求级随机
// ID,不伪造聚合键)。
func TurnKey(body []byte) string {
	if len(body) == 0 {
		return ""
	}
	var obj struct {
		Messages []struct {
			Role    string          `json:"role"`
			Content json.RawMessage `json:"content"`
		} `json:"messages"`
	}
	if err := json.Unmarshal(body, &obj); err != nil {
		return ""
	}
	for i := len(obj.Messages) - 1; i >= 0; i-- {
		if obj.Messages[i].Role != "user" {
			continue
		}
		sig := contentSignature(obj.Messages[i].Content)
		if sig == "" {
			// 最后一条 user 消息没有可签名内容(空/null/空 parts)→ 本轮不建立聚合键。
			// 不继续往前找:整轮内该消息位置恒定,往前找反而会让键随 step 漂移。
			return ""
		}
		return fmt.Sprintf("u%d:%s", i, sig)
	}
	return ""
}

// contentText 取消息 content 的文本:字符串形态直接返回;数组形态(多模态 parts)
// 拼接各 part 的 text 字段。无文本(纯图片 / null / 未知形态)返回 ""。
func contentText(raw json.RawMessage) string {
	s := strings.TrimSpace(string(raw))
	if s == "" || s == "null" {
		return ""
	}
	switch s[0] {
	case '"':
		var str string
		if err := json.Unmarshal(raw, &str); err != nil {
			return ""
		}
		return str
	case '[':
		var parts []struct {
			Text string `json:"text"`
		}
		if err := json.Unmarshal(raw, &parts); err != nil {
			return ""
		}
		var b strings.Builder
		for _, p := range parts {
			b.WriteString(p.Text)
		}
		return b.String()
	}
	return ""
}

// contentSignature 取消息 content 的确定性签名(G1 修复——纯图片轮不再碎片化):
//   - string 形态:返回文本,与 contentText 结果**完全一致**——纯文本路径键值
//     不变,存量会话的轮键/粘性键零漂移(向后兼容契约);
//   - 数组形态(多模态 parts):文本 part(type 为 "" 或 "text",与 contentText
//     的拼接口径一致)按原文无缝拼接;非文本 part 追加 "[type:摘要]"——摘要取该
//     part 原始字节的 sha256 前 8 hex。data: base64 内联图可能超长,原文入键会
//     放大派生哈希压力(见 TurnKey 头注),只入短摘要;type + 原文摘要天然区分
//     不同内容/数量的非文本 part 集合,无需额外计数占位。
//
// 无可签名内容(空 / null / 空数组 / 纯文本 part 全为空串)返回 ""(不伪造——
// 调用方回落原有的空键语义)。TurnKey 与 firstUserText(粘性兜底)共用本函数,
// 两条链路的图片盲区一并修复。
func contentSignature(raw json.RawMessage) string {
	s := strings.TrimSpace(string(raw))
	if s == "" || s == "null" {
		return ""
	}
	// 字符串形态:纯文本路径签名 == contentText 结果(键值零漂移)。
	if s[0] == '"' {
		var str string
		if err := json.Unmarshal(raw, &str); err != nil {
			return ""
		}
		return str
	}
	if s[0] != '[' {
		return ""
	}
	var parts []json.RawMessage
	if err := json.Unmarshal(raw, &parts); err != nil {
		return ""
	}
	var b strings.Builder
	hasNonText := false
	for _, pr := range parts {
		var p struct {
			Type string `json:"type"`
			Text string `json:"text"`
		}
		if err := json.Unmarshal(pr, &p); err != nil {
			return ""
		}
		if p.Type == "" || p.Type == "text" {
			// 文本 part:无缝拼接(与 contentText 完全同口径——全文本 part 的数组
			// 签名 == contentText 结果,存量键零漂移)。
			b.WriteString(p.Text)
			continue
		}
		// 非文本 part:type + 原文 sha256 前 8 hex(超长 data: URL 只入短摘要)。
		hasNonText = true
		sum := sha256.Sum256(pr)
		fmt.Fprintf(&b, "\n[%s:%s]\n", p.Type, hex.EncodeToString(sum[:4]))
	}
	out := b.String()
	if !hasNonText {
		return out
	}
	return strings.TrimSpace(out)
}

// TurnRequestID 返回轮级键对应的聚合 ID:sha256(盐|键) 前 16 字节的 hex(32 位,
// 与 NewMessageID 同形态,可直接作 B3 TraceId)。
//
// 纯派生,无缓存、无 TTL、不随进程内请求数增长内存 —— 这点与会话级的
// RequestIDForKey 相反:会话键数量有限(与粘性会话同源)可以常驻缓存,而轮级键
// 每个对话轮新增一条,缓存必须有界,派生式天然有界。
// 空键返回新随机值(无轮可聚合时保持原有的「每请求独立」行为)。
func TurnRequestID(turnKey string) string {
	if turnKey == "" {
		return NewMessageID()
	}
	sum := sha256.Sum256([]byte(deriveSalt + "|" + turnKey))
	return hex.EncodeToString(sum[:16])
}