mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
fix(claude): derive stable request-scoped metadata.user_id for converters
Closes: #5153
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
243
internal/translator/common/claude_user_id.go
Normal file
243
internal/translator/common/claude_user_id.go
Normal 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"))
|
||||
}
|
||||
286
internal/translator/common/claude_user_id_test.go
Normal file
286
internal/translator/common/claude_user_id_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user