243 lines
6.3 KiB
Go
243 lines
6.3 KiB
Go
package common
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"strings"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
// DeriveClaudeUserID returns a stable value for the Claude request field
|
|
// metadata.user_id. It preserves any caller-supplied metadata.user_id or
|
|
// OpenAI Chat Completions user field, then derives a deterministic value from
|
|
// stable client signals (prompt_cache_key, session_id, conversation_id, first user
|
|
// message content, and model/system instructions). The same conversation therefore gets
|
|
// the same user_id on every worker and every turn, while different
|
|
// conversations get different values.
|
|
func DeriveClaudeUserID(rawJSON []byte) string {
|
|
root := gjson.ParseBytes(rawJSON)
|
|
|
|
if v := root.Get("metadata.user_id"); v.Exists() && v.Type == gjson.String {
|
|
if raw := v.String(); strings.TrimSpace(raw) != "" {
|
|
return raw
|
|
}
|
|
}
|
|
if v := root.Get("user"); v.Exists() && v.Type == gjson.String {
|
|
if raw := v.String(); strings.TrimSpace(raw) != "" {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
var seed strings.Builder
|
|
|
|
if v := root.Get("prompt_cache_key"); v.Exists() {
|
|
if value := strings.TrimSpace(v.String()); value != "" {
|
|
seed.WriteString("prompt_cache_key:")
|
|
seed.WriteString(value)
|
|
}
|
|
}
|
|
|
|
if seed.Len() == 0 {
|
|
for _, path := range []string{"session_id", "sessionId"} {
|
|
if v := root.Get(path); v.Exists() {
|
|
if value := strings.TrimSpace(v.String()); value != "" {
|
|
seed.WriteString("session_id:")
|
|
seed.WriteString(value)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if seed.Len() == 0 {
|
|
conversation := root.Get("conversation")
|
|
if sid := strings.TrimSpace(conversation.Get("id").String()); sid != "" {
|
|
seed.WriteString("conversation_id:")
|
|
seed.WriteString(sid)
|
|
} else if conversation.Type == gjson.String {
|
|
if sid := strings.TrimSpace(conversation.String()); sid != "" {
|
|
seed.WriteString("conversation_id:")
|
|
seed.WriteString(sid)
|
|
}
|
|
} else if v := root.Get("conversation_id"); v.Exists() {
|
|
if sid := strings.TrimSpace(v.String()); sid != "" {
|
|
seed.WriteString("conversation_id:")
|
|
seed.WriteString(sid)
|
|
}
|
|
}
|
|
}
|
|
|
|
if seed.Len() == 0 {
|
|
if content := firstStableRequestContent(root); content != "" {
|
|
seed.WriteString("content:")
|
|
seed.WriteString(content)
|
|
}
|
|
}
|
|
|
|
if seed.Len() == 0 {
|
|
if v := root.Get("model"); v.Exists() {
|
|
if value := strings.TrimSpace(v.String()); value != "" {
|
|
seed.WriteString("model:")
|
|
seed.WriteString(value)
|
|
}
|
|
}
|
|
if v := root.Get("instructions"); v.Exists() {
|
|
seed.WriteString(";instructions:")
|
|
seed.WriteString(v.String())
|
|
}
|
|
if v := root.Get("system"); v.Exists() {
|
|
seed.WriteString(";system:")
|
|
seed.WriteString(v.String())
|
|
}
|
|
if v := root.Get("systemInstruction"); v.Exists() {
|
|
seed.WriteString(";systemInstruction:")
|
|
seed.WriteString(v.String())
|
|
}
|
|
if v := root.Get("system_instruction"); v.Exists() {
|
|
seed.WriteString(";system_instruction:")
|
|
seed.WriteString(v.String())
|
|
}
|
|
}
|
|
|
|
if seed.Len() == 0 {
|
|
return "unknown"
|
|
}
|
|
|
|
sum := sha256.Sum256([]byte(seed.String()))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func firstStableRequestContent(root gjson.Result) string {
|
|
if messages := root.Get("messages"); messages.IsArray() {
|
|
var content string
|
|
messages.ForEach(func(_, message gjson.Result) bool {
|
|
role := strings.ToLower(strings.TrimSpace(message.Get("role").String()))
|
|
if role == "user" {
|
|
content = extractTextContent(message.Get("content"))
|
|
if content != "" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if content != "" {
|
|
return content
|
|
}
|
|
}
|
|
|
|
if input := root.Get("input"); input.Exists() {
|
|
if input.Type == gjson.String {
|
|
if text := strings.TrimSpace(input.String()); text != "" {
|
|
return text
|
|
}
|
|
} else if input.IsArray() {
|
|
var content string
|
|
input.ForEach(func(_, item gjson.Result) bool {
|
|
if isResponsesUserItem(item) {
|
|
content = extractResponsesItemText(item.Get("content"))
|
|
if content != "" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if content != "" {
|
|
return content
|
|
}
|
|
}
|
|
}
|
|
|
|
if contents := root.Get("contents"); contents.IsArray() {
|
|
var content string
|
|
contents.ForEach(func(_, contentItem gjson.Result) bool {
|
|
role := strings.ToLower(strings.TrimSpace(contentItem.Get("role").String()))
|
|
// In Gemini API format, missing role defaults to "user"
|
|
if role == "" || role == "user" {
|
|
if parts := contentItem.Get("parts"); parts.IsArray() {
|
|
var texts []string
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if IsGeminiThoughtPart(part) {
|
|
return true
|
|
}
|
|
if text := part.Get("text"); text.Exists() {
|
|
if val := strings.TrimSpace(text.String()); val != "" {
|
|
texts = append(texts, val)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if len(texts) > 0 {
|
|
content = strings.Join(texts, "\n")
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if content != "" {
|
|
return content
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func extractTextContent(content gjson.Result) string {
|
|
if content.Type == gjson.String {
|
|
return strings.TrimSpace(content.String())
|
|
}
|
|
if !content.IsArray() {
|
|
return ""
|
|
}
|
|
var texts []string
|
|
content.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("type").String() == "text" {
|
|
if text := part.Get("text"); text.Exists() {
|
|
if val := strings.TrimSpace(text.String()); val != "" {
|
|
texts = append(texts, val)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return strings.TrimSpace(strings.Join(texts, "\n"))
|
|
}
|
|
|
|
func isResponsesUserItem(item gjson.Result) bool {
|
|
role := strings.ToLower(strings.TrimSpace(item.Get("role").String()))
|
|
if role == "user" {
|
|
return true
|
|
}
|
|
if role == "system" || role == "developer" || role == "assistant" {
|
|
return false
|
|
}
|
|
typ := strings.ToLower(strings.TrimSpace(item.Get("type").String()))
|
|
if typ == "message" {
|
|
// Non-assistant / non-system message defaults to user
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func extractResponsesItemText(content gjson.Result) string {
|
|
if content.Type == gjson.String {
|
|
return strings.TrimSpace(content.String())
|
|
}
|
|
if !content.IsArray() {
|
|
return ""
|
|
}
|
|
var texts []string
|
|
content.ForEach(func(_, part gjson.Result) bool {
|
|
switch part.Get("type").String() {
|
|
case "input_text", "output_text", "text":
|
|
if text := part.Get("text"); text.Exists() {
|
|
if val := strings.TrimSpace(text.String()); val != "" {
|
|
texts = append(texts, val)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return strings.TrimSpace(strings.Join(texts, "\n"))
|
|
}
|