fix(claude): derive stable request-scoped metadata.user_id for converters

Closes: #5153
This commit is contained in:
Luis Pater
2026-08-22 16:01:25 +08:00
parent b3f72cef65
commit ab8f00dbd9
8 changed files with 768 additions and 71 deletions

View File

@@ -6,12 +6,9 @@
package gemini
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
@@ -20,12 +17,6 @@ import (
"github.com/tidwall/sjson"
)
var (
user = ""
account = ""
session = ""
)
// ConvertGeminiRequestToClaude parses and transforms a Gemini API request into Claude Code API format.
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the Claude Code API.
@@ -47,22 +38,11 @@ var (
func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := inputRawJSON
if account == "" {
u, _ := uuid.NewRandom()
account = u.String()
}
if session == "" {
u, _ := uuid.NewRandom()
session = u.String()
}
if user == "" {
sum := sha256.Sum256([]byte(account + session))
user = hex.EncodeToString(sum[:])
}
userID := fmt.Sprintf("user_%s_account_%s_session_%s", user, account, session)
userID := translatorcommon.DeriveClaudeUserID(rawJSON)
// Base Claude message payload
out := []byte(fmt.Sprintf(`{"model":"","max_tokens":32000,"messages":[],"metadata":{"user_id":"%s"}}`, userID))
out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`)
out, _ = sjson.SetBytes(out, "metadata.user_id", userID)
root := gjson.ParseBytes(rawJSON)
messageAccumulator := translatorcommon.NewClaudeMessageAccumulator(int(root.Get("contents.#").Int()) + 1)

View File

@@ -253,3 +253,67 @@ func TestConvertGeminiRequestToClaude_DeterministicToolIDs(t *testing.T) {
t.Fatalf("expected second tool pair to have id %q, got call=%q, resp=%q", wantID2, gotCall2, gotResp2)
}
}
func TestConvertGeminiRequestToClaude_PreservesCallerSuppliedMetadataUserID(t *testing.T) {
testCases := []struct {
name string
rawJSON string
expected string
}{
{
name: "plain string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"custom-gemini-user-123"},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
expected: "custom-gemini-user-123",
},
{
name: "special characters and json string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"foo\"bar\nbaz\\qux"},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
expected: "foo\"bar\nbaz\\qux",
},
{
name: "claude code json format",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"{\"device_id\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"session_id\":\"11111111-2222-4333-8444-555555555555\"}"},"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
expected: `{"device_id":"0000000000000000000000000000000000000000000000000000000000000000","session_id":"11111111-2222-4333-8444-555555555555"}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out := ConvertGeminiRequestToClaude("claude-test", []byte(tc.rawJSON), false)
if !gjson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
got := gjson.GetBytes(out, "metadata.user_id").String()
if got != tc.expected {
t.Fatalf("metadata.user_id = %q, want %q", got, tc.expected)
}
})
}
}
func TestConvertGeminiRequestToClaude_DifferentSessionsProduceDifferentUserIDs(t *testing.T) {
a := []byte(`{"model":"claude-test","prompt_cache_key":"gemini-session-a","contents":[{"role":"user","parts":[{"text":"hello"}]}]}`)
b := []byte(`{"model":"claude-test","prompt_cache_key":"gemini-session-b","contents":[{"role":"user","parts":[{"text":"hello"}]}]}`)
outA := ConvertGeminiRequestToClaude("claude-test", a, false)
outB := ConvertGeminiRequestToClaude("claude-test", b, false)
idA := gjson.GetBytes(outA, "metadata.user_id").String()
idB := gjson.GetBytes(outB, "metadata.user_id").String()
if idA == idB {
t.Fatalf("different prompt_cache_key produced identical metadata.user_id: %q", idA)
}
}
func TestConvertGeminiRequestToClaude_DefaultRoleDifferentContentProducesDifferentUserIDs(t *testing.T) {
a := []byte(`{"contents":[{"parts":[{"text":"first prompt"}]}]}`)
b := []byte(`{"contents":[{"parts":[{"text":"second prompt"}]}]}`)
outA := ConvertGeminiRequestToClaude("claude-test", a, false)
outB := ConvertGeminiRequestToClaude("claude-test", b, false)
idA := gjson.GetBytes(outA, "metadata.user_id").String()
idB := gjson.GetBytes(outB, "metadata.user_id").String()
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id without role, got idA=%q idB=%q", idA, idB)
}
if idA == idB {
t.Fatalf("different prompt texts without role produced identical metadata.user_id: %q", idA)
}
}

View File

@@ -6,12 +6,8 @@
package chat_completions
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
@@ -20,12 +16,6 @@ import (
"github.com/tidwall/sjson"
)
var (
user = ""
account = ""
session = ""
)
// ConvertOpenAIRequestToClaude parses and transforms an OpenAI Chat Completions API request into Claude Code API format.
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the Claude Code API.
@@ -56,22 +46,11 @@ func ConvertOpenAIRequestToClaudeWithCompat(modelName string, inputRawJSON []byt
func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) []byte {
rawJSON := inputRawJSON
if account == "" {
u, _ := uuid.NewRandom()
account = u.String()
}
if session == "" {
u, _ := uuid.NewRandom()
session = u.String()
}
if user == "" {
sum := sha256.Sum256([]byte(account + session))
user = hex.EncodeToString(sum[:])
}
userID := fmt.Sprintf("user_%s_account_%s_session_%s", user, account, session)
userID := common.DeriveClaudeUserID(rawJSON)
// Base Claude Code API template with default max_tokens value
out := []byte(fmt.Sprintf(`{"model":"","max_tokens":32000,"messages":[],"metadata":{"user_id":"%s"}}`, userID))
out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`)
out, _ = sjson.SetBytes(out, "metadata.user_id", userID)
root := gjson.ParseBytes(rawJSON)

View File

@@ -768,3 +768,79 @@ func TestConvertOpenAIRequestToClaude_MaxTokensAndMaxCompletionTokens(t *testing
})
}
}
func TestConvertOpenAIRequestToClaude_PreservesCallerSuppliedMetadataUserID(t *testing.T) {
testCases := []struct {
name string
rawJSON string
expected string
}{
{
name: "plain string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"custom-user-123"},"messages":[{"role":"user","content":"hello"}]}`,
expected: "custom-user-123",
},
{
name: "special characters and json string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"foo\"bar\nbaz\\qux"},"messages":[{"role":"user","content":"hello"}]}`,
expected: "foo\"bar\nbaz\\qux",
},
{
name: "claude code json format",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"{\"device_id\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"session_id\":\"11111111-2222-4333-8444-555555555555\"}"},"messages":[{"role":"user","content":"hello"}]}`,
expected: `{"device_id":"0000000000000000000000000000000000000000000000000000000000000000","session_id":"11111111-2222-4333-8444-555555555555"}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out := ConvertOpenAIRequestToClaude("claude-test", []byte(tc.rawJSON), false)
if !gjson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
got := gjson.GetBytes(out, "metadata.user_id").String()
if got != tc.expected {
t.Fatalf("metadata.user_id = %q, want %q", got, tc.expected)
}
})
}
}
func TestConvertOpenAIRequestToClaude_PreservesOpenAIUserField(t *testing.T) {
raw := []byte(`{"model":"claude-test","user":"openai-user-456","messages":[{"role":"user","content":"hello"}]}`)
out := ConvertOpenAIRequestToClaude("claude-test", raw, false)
if !gjson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
got := gjson.GetBytes(out, "metadata.user_id").String()
if got != "openai-user-456" {
t.Fatalf("metadata.user_id = %q, want %q", got, "openai-user-456")
}
}
func TestConvertOpenAIRequestToClaude_DifferentSessionsProduceDifferentUserIDs(t *testing.T) {
a := []byte(`{"model":"claude-test","prompt_cache_key":"session-a","messages":[{"role":"user","content":"hello"}]}`)
b := []byte(`{"model":"claude-test","prompt_cache_key":"session-b","messages":[{"role":"user","content":"hello"}]}`)
outA := ConvertOpenAIRequestToClaude("claude-test", a, false)
outB := ConvertOpenAIRequestToClaude("claude-test", b, false)
idA := gjson.GetBytes(outA, "metadata.user_id").String()
idB := gjson.GetBytes(outB, "metadata.user_id").String()
if idA == idB {
t.Fatalf("different prompt_cache_key produced identical metadata.user_id: %q", idA)
}
}
func TestConvertOpenAIRequestToClaude_DeterministicWithoutSessionKey(t *testing.T) {
first := []byte(`{"model":"claude-test","messages":[{"role":"user","content":"stable first message"}]}`)
second := []byte(`{"model":"claude-test","messages":[{"role":"user","content":"stable first message"},{"role":"assistant","content":"hi"},{"role":"user","content":"second message"}]}`)
outFirst := ConvertOpenAIRequestToClaude("claude-test", first, false)
outSecond := ConvertOpenAIRequestToClaude("claude-test", second, false)
idFirst := gjson.GetBytes(outFirst, "metadata.user_id").String()
idSecond := gjson.GetBytes(outSecond, "metadata.user_id").String()
if idFirst == "" || idFirst == "unknown" {
t.Fatalf("expected non-empty derived user_id, got %q", idFirst)
}
if idFirst != idSecond {
t.Fatalf("turn growth changed derived user_id: %q vs %q", idFirst, idSecond)
}
}

View File

@@ -1,12 +1,8 @@
package responses
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
@@ -16,12 +12,6 @@ import (
"github.com/tidwall/sjson"
)
var (
user = ""
account = ""
session = ""
)
// ConvertOpenAIResponsesRequestToClaude transforms an OpenAI Responses API request
// into a Claude Messages API request using only gjson/sjson for JSON handling.
// It supports:
@@ -46,22 +36,11 @@ func ConvertOpenAIResponsesRequestToClaudeWithCompat(modelName string, inputRawJ
func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) []byte {
rawJSON := inputRawJSON
if account == "" {
u, _ := uuid.NewRandom()
account = u.String()
}
if session == "" {
u, _ := uuid.NewRandom()
session = u.String()
}
if user == "" {
sum := sha256.Sum256([]byte(account + session))
user = hex.EncodeToString(sum[:])
}
userID := fmt.Sprintf("user_%s_account_%s_session_%s", user, account, session)
userID := common.DeriveClaudeUserID(rawJSON)
// Base Claude message payload
out := []byte(fmt.Sprintf(`{"model":"","max_tokens":32000,"messages":[],"metadata":{"user_id":"%s"}}`, userID))
out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`)
out, _ = sjson.SetBytes(out, "metadata.user_id", userID)
root := gjson.ParseBytes(rawJSON)

View File

@@ -1362,3 +1362,93 @@ func TestConvertOpenAIResponsesRequestToClaude_ServiceTierToSpeed(t *testing.T)
})
}
}
func TestConvertOpenAIResponsesRequestToClaude_PreservesCallerSuppliedMetadataUserID(t *testing.T) {
testCases := []struct {
name string
rawJSON string
expected string
}{
{
name: "plain string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"custom-resp-user-123"},"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`,
expected: "custom-resp-user-123",
},
{
name: "special characters and json string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"foo\"bar\nbaz\\qux"},"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`,
expected: "foo\"bar\nbaz\\qux",
},
{
name: "claude code json format",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"{\"device_id\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"session_id\":\"11111111-2222-4333-8444-555555555555\"}"},"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`,
expected: `{"device_id":"0000000000000000000000000000000000000000000000000000000000000000","session_id":"11111111-2222-4333-8444-555555555555"}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out := ConvertOpenAIResponsesRequestToClaude("claude-test", []byte(tc.rawJSON), false)
if !gjson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
got := gjson.GetBytes(out, "metadata.user_id").String()
if got != tc.expected {
t.Fatalf("metadata.user_id = %q, want %q", got, tc.expected)
}
})
}
}
func TestConvertOpenAIResponsesRequestToClaude_PreservesUserField(t *testing.T) {
raw := []byte(`{"model":"claude-test","user":"openai-resp-user-456","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`)
out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
if !gjson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
got := gjson.GetBytes(out, "metadata.user_id").String()
if got != "openai-resp-user-456" {
t.Fatalf("metadata.user_id = %q, want %q", got, "openai-resp-user-456")
}
}
func TestConvertOpenAIResponsesRequestToClaude_DifferentSessionsProduceDifferentUserIDs(t *testing.T) {
a := []byte(`{"model":"claude-test","prompt_cache_key":"resp-session-a","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`)
b := []byte(`{"model":"claude-test","prompt_cache_key":"resp-session-b","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`)
outA := ConvertOpenAIResponsesRequestToClaude("claude-test", a, false)
outB := ConvertOpenAIResponsesRequestToClaude("claude-test", b, false)
idA := gjson.GetBytes(outA, "metadata.user_id").String()
idB := gjson.GetBytes(outB, "metadata.user_id").String()
if idA == idB {
t.Fatalf("different prompt_cache_key produced identical metadata.user_id: %q", idA)
}
}
func TestConvertOpenAIResponsesRequestToClaude_DifferentUserContentWithSameSystemPrompt(t *testing.T) {
rawA := []byte(`{
"model": "claude-test",
"instructions": "global instruction",
"input": [
{"type": "message", "role": "system", "content": "system context"},
{"type": "message", "role": "user", "content": "user question A"}
]
}`)
rawB := []byte(`{
"model": "claude-test",
"instructions": "global instruction",
"input": [
{"type": "message", "role": "system", "content": "system context"},
{"type": "message", "role": "user", "content": "user question B"}
]
}`)
outA := ConvertOpenAIResponsesRequestToClaude("claude-test", rawA, false)
outB := ConvertOpenAIResponsesRequestToClaude("claude-test", rawB, false)
idA := gjson.GetBytes(outA, "metadata.user_id").String()
idB := gjson.GetBytes(outB, "metadata.user_id").String()
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id, got idA=%q idB=%q", idA, idB)
}
if idA == idB {
t.Fatalf("different user questions with same system prompt produced identical metadata.user_id: %q", idA)
}
}

View File

@@ -0,0 +1,243 @@
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"))
}

View File

@@ -0,0 +1,286 @@
package common
import (
"testing"
)
func TestDeriveClaudeUserID_SameConversationIsStable(t *testing.T) {
raw := []byte(`{"model":"claude-test","messages":[{"role":"user","content":"hello"}]}`)
first := DeriveClaudeUserID(raw)
second := DeriveClaudeUserID(raw)
if first == "" {
t.Fatal("expected non-empty user_id")
}
if first != second {
t.Fatalf("same conversation produced different user_id: %q vs %q", first, second)
}
}
func TestDeriveClaudeUserID_PreservesCallerSuppliedMetadataUserID(t *testing.T) {
testCases := []struct {
name string
rawJSON string
expected string
}{
{
name: "plain string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"caller-123"},"messages":[{"role":"user","content":"hello"}]}`,
expected: "caller-123",
},
{
name: "whitespace preserved",
rawJSON: `{"model":"claude-test","metadata":{"user_id":" caller-spaces "},"messages":[{"role":"user","content":"hello"}]}`,
expected: " caller-spaces ",
},
{
name: "special characters",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"foo\"bar\nbaz\\qux"},"messages":[{"role":"user","content":"hello"}]}`,
expected: "foo\"bar\nbaz\\qux",
},
{
name: "claude code json string",
rawJSON: `{"model":"claude-test","metadata":{"user_id":"{\"device_id\":\"dev-1\",\"session_id\":\"sess-1\"}"},"messages":[{"role":"user","content":"hello"}]}`,
expected: `{"device_id":"dev-1","session_id":"sess-1"}`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := DeriveClaudeUserID([]byte(tc.rawJSON)); got != tc.expected {
t.Fatalf("caller-supplied metadata.user_id not preserved, got %q want %q", got, tc.expected)
}
})
}
}
func TestDeriveClaudeUserID_PreservesOpenAIUserField(t *testing.T) {
raw := []byte(`{"model":"claude-test","user":"openai-user-456","messages":[{"role":"user","content":"hello"}]}`)
if got := DeriveClaudeUserID(raw); got != "openai-user-456" {
t.Fatalf("caller-supplied user not preserved, got %q", got)
}
}
func TestDeriveClaudeUserID_MetadataUserIDTakesPriorityOverUserField(t *testing.T) {
raw := []byte(`{"model":"claude-test","metadata":{"user_id":"meta-user-1"},"user":"openai-user-2","messages":[{"role":"user","content":"hello"}]}`)
if got := DeriveClaudeUserID(raw); got != "meta-user-1" {
t.Fatalf("metadata.user_id should take priority over user field, got %q", got)
}
}
func TestDeriveClaudeUserID_CaseInsensitiveUserRole(t *testing.T) {
rawA := []byte(`{"model":"claude-test","messages":[{"role":"User","content":"message A"}]}`)
rawB := []byte(`{"model":"claude-test","messages":[{"role":"USER","content":"message B"}]}`)
idA := DeriveClaudeUserID(rawA)
idB := DeriveClaudeUserID(rawB)
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id for uppercase User role, got idA=%q idB=%q", idA, idB)
}
if idA == idB {
t.Fatalf("different messages with User role produced same user_id: %q", idA)
}
}
func TestDeriveClaudeUserID_IgnoresNonStringMetadataUserIDOrUser(t *testing.T) {
raw := []byte(`{"model":"claude-test","metadata":{"user_id":12345},"user":true,"messages":[{"role":"user","content":"hello"}]}`)
got := DeriveClaudeUserID(raw)
if got == "" || got == "12345" || got == "true" {
t.Fatalf("non-string user_id should be ignored and derived, got %q", got)
}
}
func TestDeriveClaudeUserID_DifferentSessionsAreDifferent(t *testing.T) {
a := []byte(`{"model":"claude-test","prompt_cache_key":"session-a","messages":[{"role":"user","content":"hello"}]}`)
b := []byte(`{"model":"claude-test","prompt_cache_key":"session-b","messages":[{"role":"user","content":"hello"}]}`)
idA := DeriveClaudeUserID(a)
idB := DeriveClaudeUserID(b)
if idA == idB {
t.Fatalf("different prompt_cache_key produced same user_id: %q", idA)
}
}
func TestDeriveClaudeUserID_SessionIDVariants(t *testing.T) {
a := []byte(`{"model":"claude-test","session_id":"sess-a","messages":[{"role":"user","content":"hello"}]}`)
b := []byte(`{"model":"claude-test","sessionId":"sess-b","messages":[{"role":"user","content":"hello"}]}`)
idA := DeriveClaudeUserID(a)
idB := DeriveClaudeUserID(b)
if idA == "" || idB == "" {
t.Fatal("expected non-empty user_id for session_id/sessionId")
}
if idA == idB {
t.Fatalf("different session ids produced same user_id: %q", idA)
}
}
func TestDeriveClaudeUserID_ConversationIDVariants(t *testing.T) {
cObj := []byte(`{"model":"claude-test","conversation":{"id":"conv-1"},"messages":[{"role":"user","content":"hello"}]}`)
cStr := []byte(`{"model":"claude-test","conversation":"conv-2","messages":[{"role":"user","content":"hello"}]}`)
cFlat := []byte(`{"model":"claude-test","conversation_id":"conv-3","messages":[{"role":"user","content":"hello"}]}`)
idObj := DeriveClaudeUserID(cObj)
idStr := DeriveClaudeUserID(cStr)
idFlat := DeriveClaudeUserID(cFlat)
if idObj == "" || idStr == "" || idFlat == "" {
t.Fatal("expected non-empty user_id for conversation variants")
}
if idObj == idStr || idObj == idFlat || idStr == idFlat {
t.Fatalf("different conversation ids produced identical user_ids: obj=%q str=%q flat=%q", idObj, idStr, idFlat)
}
}
func TestDeriveClaudeUserID_TurnGrowthKeepsSameUserID(t *testing.T) {
first := []byte(`{"model":"claude-test","prompt_cache_key":"session-1","messages":[{"role":"user","content":"hello"}]}`)
second := []byte(`{"model":"claude-test","prompt_cache_key":"session-1","messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"},{"role":"user","content":"follow up"}]}`)
idFirst := DeriveClaudeUserID(first)
idSecond := DeriveClaudeUserID(second)
if idFirst != idSecond {
t.Fatalf("conversation turn growth changed user_id: %q vs %q", idFirst, idSecond)
}
}
func TestDeriveClaudeUserID_TurnGrowthWithoutSessionKeyKeepsSameUserID(t *testing.T) {
first := []byte(`{"model":"claude-test","messages":[{"role":"user","content":"first prompt"}]}`)
second := []byte(`{"model":"claude-test","messages":[{"role":"user","content":"first prompt"},{"role":"assistant","content":"hi"},{"role":"user","content":"second prompt"}]}`)
idFirst := DeriveClaudeUserID(first)
idSecond := DeriveClaudeUserID(second)
if idFirst == "" || idFirst == "unknown" {
t.Fatalf("expected valid derived user_id, got %q", idFirst)
}
if idFirst != idSecond {
t.Fatalf("conversation turn growth without session key changed user_id: %q vs %q", idFirst, idSecond)
}
}
func TestDeriveClaudeUserID_GeminiTurnGrowthWithoutSessionKeyKeepsSameUserID(t *testing.T) {
first := []byte(`{"contents":[{"role":"user","parts":[{"text":"first gemini prompt"}]}]}`)
second := []byte(`{"contents":[{"role":"user","parts":[{"text":"first gemini prompt"}]},{"role":"model","parts":[{"text":"answer"}]},{"role":"user","parts":[{"text":"second prompt"}]}]}`)
idFirst := DeriveClaudeUserID(first)
idSecond := DeriveClaudeUserID(second)
if idFirst == "" || idFirst == "unknown" {
t.Fatalf("expected valid derived user_id, got %q", idFirst)
}
if idFirst != idSecond {
t.Fatalf("gemini turn growth without session key changed user_id: %q vs %q", idFirst, idSecond)
}
}
func TestDeriveClaudeUserID_FirstMessageFallback(t *testing.T) {
rawA := []byte(`{"model":"claude-test","messages":[{"role":"user","content":"message A"}]}`)
rawB := []byte(`{"model":"claude-test","messages":[{"role":"user","content":"message B"}]}`)
idA := DeriveClaudeUserID(rawA)
idB := DeriveClaudeUserID(rawB)
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id, got idA=%q idB=%q", idA, idB)
}
if idA == idB {
t.Fatalf("different first messages produced same user_id: %q", idA)
}
}
func TestDeriveClaudeUserID_ResponsesInputString(t *testing.T) {
rawA := []byte(`{"model":"claude-test","input":"hello world A"}`)
rawB := []byte(`{"model":"claude-test","input":"hello world B"}`)
idA := DeriveClaudeUserID(rawA)
idB := DeriveClaudeUserID(rawB)
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id for input string, got idA=%q idB=%q", idA, idB)
}
if idA == idB {
t.Fatalf("different input strings produced same user_id: %q", idA)
}
}
func TestDeriveClaudeUserID_ResponsesInputArraySkipsSystemLevelItems(t *testing.T) {
rawA := []byte(`{
"model": "claude-test",
"input": [
{"type": "message", "role": "system", "content": "system prompt"},
{"type": "message", "role": "developer", "content": "dev prompt"},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "user message A"}]}
]
}`)
rawB := []byte(`{
"model": "claude-test",
"input": [
{"type": "message", "role": "system", "content": "system prompt"},
{"type": "message", "role": "developer", "content": "dev prompt"},
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "user message B"}]}
]
}`)
idA := DeriveClaudeUserID(rawA)
idB := DeriveClaudeUserID(rawB)
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id, got idA=%q idB=%q", idA, idB)
}
if idA == idB {
t.Fatalf("different user messages with same system prompt produced identical user_id: %q", idA)
}
}
func TestDeriveClaudeUserID_GeminiContentsDefaultRole(t *testing.T) {
rawA := []byte(`{"contents":[{"parts":[{"text":"gemini message A"}]}]}`)
rawB := []byte(`{"contents":[{"parts":[{"text":"gemini message B"}]}]}`)
idA := DeriveClaudeUserID(rawA)
idB := DeriveClaudeUserID(rawB)
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id for gemini without explicit role, got idA=%q idB=%q", idA, idB)
}
if idA == idB {
t.Fatalf("different gemini messages produced same user_id: %q", idA)
}
}
func TestDeriveClaudeUserID_GeminiContentsMultipleTextParts(t *testing.T) {
rawA := []byte(`{"contents":[{"role":"user","parts":[{"text":"Prefix"},{"text":"Question A"}]}]}`)
rawB := []byte(`{"contents":[{"role":"user","parts":[{"text":"Prefix"},{"text":"Question B"}]}]}`)
idA := DeriveClaudeUserID(rawA)
idB := DeriveClaudeUserID(rawB)
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id for gemini multiple parts, got idA=%q idB=%q", idA, idB)
}
if idA == idB {
t.Fatalf("different second parts produced same user_id: %q", idA)
}
}
func TestDeriveClaudeUserID_GeminiContentsSkipsThoughtParts(t *testing.T) {
raw := []byte(`{
"contents": [
{
"role": "user",
"parts": [
{"thought": true, "text": "internal thought"},
{"text": "visible content"}
]
}
]
}`)
rawOnlyVisible := []byte(`{
"contents": [
{
"role": "user",
"parts": [
{"text": "visible content"}
]
}
]
}`)
id1 := DeriveClaudeUserID(raw)
id2 := DeriveClaudeUserID(rawOnlyVisible)
if id1 != id2 {
t.Fatalf("thought part changed derived user_id: %q vs %q", id1, id2)
}
}
func TestDeriveClaudeUserID_GeminiSystemInstruction(t *testing.T) {
rawCamel := []byte(`{"systemInstruction":{"parts":[{"text":"system rule A"}]}}`)
rawSnake := []byte(`{"system_instruction":{"parts":[{"text":"system rule B"}]}}`)
idCamel := DeriveClaudeUserID(rawCamel)
idSnake := DeriveClaudeUserID(rawSnake)
if idCamel == "" || idSnake == "" || idCamel == "unknown" || idSnake == "unknown" {
t.Fatalf("expected valid derived user_id for systemInstruction, got camel=%q snake=%q", idCamel, idSnake)
}
if idCamel == idSnake {
t.Fatalf("different system instructions produced same user_id: %q", idCamel)
}
}