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])
}
|