diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index b155a232f..96f02b430 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -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) diff --git a/internal/translator/claude/gemini/claude_gemini_request_test.go b/internal/translator/claude/gemini/claude_gemini_request_test.go index 03ec884b1..1d81e37ba 100644 --- a/internal/translator/claude/gemini/claude_gemini_request_test.go +++ b/internal/translator/claude/gemini/claude_gemini_request_test.go @@ -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) + } +} diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index 443b57e71..641e819f5 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -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) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index f3de6fba8..070aee20f 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -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) + } +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 3813bf867..f935594b9 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -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) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index c6480f184..9799aa836 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -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) + } +} diff --git a/internal/translator/common/claude_user_id.go b/internal/translator/common/claude_user_id.go new file mode 100644 index 000000000..0862a5d4b --- /dev/null +++ b/internal/translator/common/claude_user_id.go @@ -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")) +} diff --git a/internal/translator/common/claude_user_id_test.go b/internal/translator/common/claude_user_id_test.go new file mode 100644 index 000000000..fe3a3059b --- /dev/null +++ b/internal/translator/common/claude_user_id_test.go @@ -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) + } +}