a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
9.43 kB
// 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])
}