Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
108
backend/internal/translator/common/bytes.go
Normal file
108
backend/internal/translator/common/bytes.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
func GeminiTokenCountJSON(count int64) []byte {
|
||||
out := make([]byte, 0, 96)
|
||||
out = append(out, `{"totalTokens":`...)
|
||||
out = strconv.AppendInt(out, count, 10)
|
||||
out = append(out, `,"promptTokensDetails":[{"modality":"TEXT","tokenCount":`...)
|
||||
out = strconv.AppendInt(out, count, 10)
|
||||
out = append(out, `}]}`...)
|
||||
return out
|
||||
}
|
||||
|
||||
func ClaudeInputTokensJSON(count int64) []byte {
|
||||
out := make([]byte, 0, 32)
|
||||
out = append(out, `{"input_tokens":`...)
|
||||
out = strconv.AppendInt(out, count, 10)
|
||||
out = append(out, '}')
|
||||
return out
|
||||
}
|
||||
|
||||
// NewRawArrayItems creates a raw item slice sized for the expected input.
|
||||
func NewRawArrayItems(capacity int64) [][]byte {
|
||||
if capacity <= 0 {
|
||||
return nil
|
||||
}
|
||||
return make([][]byte, 0, int(capacity))
|
||||
}
|
||||
|
||||
func JoinRawArray(items [][]byte) []byte {
|
||||
if len(items) == 0 {
|
||||
return []byte("[]")
|
||||
}
|
||||
size := len(items) + 1
|
||||
for _, item := range items {
|
||||
size += len(item)
|
||||
}
|
||||
out := make([]byte, 0, size)
|
||||
out = append(out, '[')
|
||||
for i, item := range items {
|
||||
if i > 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, item...)
|
||||
}
|
||||
return append(out, ']')
|
||||
}
|
||||
|
||||
// SetRawArrayItems replaces an empty JSON array at path with raw items.
|
||||
// The single-item path avoids allocating an intermediate joined array.
|
||||
func SetRawArrayItems(data []byte, path string, items [][]byte) []byte {
|
||||
if len(items) == 0 {
|
||||
return data
|
||||
}
|
||||
if len(items) == 1 {
|
||||
array := gjson.GetBytes(data, path)
|
||||
if array.Raw == "[]" && array.Index >= 0 && array.Index+len(array.Raw) <= len(data) {
|
||||
out := make([]byte, 0, len(data)+len(items[0]))
|
||||
out = append(out, data[:array.Index]...)
|
||||
out = append(out, '[')
|
||||
out = append(out, items[0]...)
|
||||
out = append(out, ']')
|
||||
return append(out, data[array.Index+len(array.Raw):]...)
|
||||
}
|
||||
}
|
||||
data, _ = sjson.SetRawBytes(data, path, JoinRawArray(items))
|
||||
return data
|
||||
}
|
||||
|
||||
func SSEEventData(event string, payload []byte) []byte {
|
||||
out := make([]byte, 0, len(event)+len(payload)+14)
|
||||
out = append(out, "event: "...)
|
||||
out = append(out, event...)
|
||||
out = append(out, '\n')
|
||||
out = append(out, "data: "...)
|
||||
out = append(out, payload...)
|
||||
return out
|
||||
}
|
||||
|
||||
func AppendSSEEventString(out []byte, event, payload string, trailingNewlines int) []byte {
|
||||
out = append(out, "event: "...)
|
||||
out = append(out, event...)
|
||||
out = append(out, '\n')
|
||||
out = append(out, "data: "...)
|
||||
out = append(out, payload...)
|
||||
for i := 0; i < trailingNewlines; i++ {
|
||||
out = append(out, '\n')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func AppendSSEEventBytes(out []byte, event string, payload []byte, trailingNewlines int) []byte {
|
||||
out = append(out, "event: "...)
|
||||
out = append(out, event...)
|
||||
out = append(out, '\n')
|
||||
out = append(out, "data: "...)
|
||||
out = append(out, payload...)
|
||||
for i := 0; i < trailingNewlines; i++ {
|
||||
out = append(out, '\n')
|
||||
}
|
||||
return out
|
||||
}
|
||||
56
backend/internal/translator/common/bytes_test.go
Normal file
56
backend/internal/translator/common/bytes_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestJoinRawArray(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items [][]byte
|
||||
want string
|
||||
}{
|
||||
{name: "empty", want: "[]"},
|
||||
{name: "single", items: [][]byte{[]byte(`{"id":1}`)}, want: `[{"id":1}]`},
|
||||
{name: "multiple", items: [][]byte{[]byte(`{"id":1}`), []byte(`{"id":2}`)}, want: `[{"id":1},{"id":2}]`},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := string(JoinRawArray(test.items)); got != test.want {
|
||||
t.Fatalf("JoinRawArray() = %s, want %s", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRawArrayItems(t *testing.T) {
|
||||
if items := NewRawArrayItems(0); items != nil {
|
||||
t.Fatalf("NewRawArrayItems(0) = %#v, want nil", items)
|
||||
}
|
||||
if items := NewRawArrayItems(3); len(items) != 0 || cap(items) != 3 {
|
||||
t.Fatalf("NewRawArrayItems(3) len = %d, cap = %d; want len 0, cap 3", len(items), cap(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRawArrayItems(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
path string
|
||||
items [][]byte
|
||||
want string
|
||||
}{
|
||||
{name: "empty", data: `{"items":[]}`, path: "items", want: `{"items":[]}`},
|
||||
{name: "single nested", data: `{"before":1,"request":{"contents":[]},"after":2}`, path: "request.contents", items: [][]byte{[]byte(`{"id":1}`)}, want: `{"before":1,"request":{"contents":[{"id":1}]},"after":2}`},
|
||||
{name: "single fallback", data: `{"items":[{"old":1},{"old":2}]}`, path: "items", items: [][]byte{[]byte(`{"id":1}`)}, want: `{"items":[{"id":1}]}`},
|
||||
{name: "multiple", data: `{"items":[]}`, path: "items", items: [][]byte{[]byte(`{"id":1}`), []byte(`{"id":2}`)}, want: `{"items":[{"id":1},{"id":2}]}`},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := SetRawArrayItems([]byte(test.data), test.path, test.items)
|
||||
if string(got) != test.want {
|
||||
t.Fatalf("SetRawArrayItems() = %s, want %s", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
67
backend/internal/translator/common/cache_control.go
Normal file
67
backend/internal/translator/common/cache_control.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// AttachCacheControl copies a Claude-compatible cache_control object from src onto dst.
|
||||
// Returns dst unchanged when cache_control is missing or not an object.
|
||||
func AttachCacheControl(dst []byte, src gjson.Result) []byte {
|
||||
cc := src.Get("cache_control")
|
||||
if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() {
|
||||
return dst
|
||||
}
|
||||
out, err := sjson.SetRawBytes(dst, "cache_control", []byte(cc.Raw))
|
||||
if err != nil {
|
||||
return dst
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AttachMessageCacheControl applies message-level cache_control onto the last content block.
|
||||
// Part-level cache_control wins when the last block already has one.
|
||||
// String content is promoted to a content array so Claude can accept cache_control.
|
||||
func AttachMessageCacheControl(msg []byte, src gjson.Result) []byte {
|
||||
cc := src.Get("cache_control")
|
||||
if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() {
|
||||
return msg
|
||||
}
|
||||
|
||||
content := gjson.GetBytes(msg, "content")
|
||||
if content.IsArray() {
|
||||
arr := content.Array()
|
||||
if len(arr) == 0 {
|
||||
return msg
|
||||
}
|
||||
lastIdx := len(arr) - 1
|
||||
if arr[lastIdx].Get("cache_control").Exists() {
|
||||
return msg
|
||||
}
|
||||
path := fmt.Sprintf("content.%d.cache_control", lastIdx)
|
||||
out, err := sjson.SetRawBytes(msg, path, []byte(cc.Raw))
|
||||
if err != nil {
|
||||
return msg
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if content.Type != gjson.String {
|
||||
return msg
|
||||
}
|
||||
|
||||
textPart := []byte(`{"type":"text","text":""}`)
|
||||
textPart, _ = sjson.SetBytes(textPart, "text", content.String())
|
||||
textPart, errSet := sjson.SetRawBytes(textPart, "cache_control", []byte(cc.Raw))
|
||||
if errSet != nil {
|
||||
return msg
|
||||
}
|
||||
out, err := sjson.SetRawBytes(msg, "content", []byte("[]"))
|
||||
if err != nil {
|
||||
return msg
|
||||
}
|
||||
out, _ = sjson.SetRawBytes(out, "content.-1", textPart)
|
||||
return out
|
||||
}
|
||||
56
backend/internal/translator/common/cache_control_test.go
Normal file
56
backend/internal/translator/common/cache_control_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAttachCacheControl_CopiesObject(t *testing.T) {
|
||||
src := gjson.Parse(`{"text":"hi","cache_control":{"type":"ephemeral","ttl":"5m"}}`)
|
||||
dst := []byte(`{"type":"text","text":"hi"}`)
|
||||
|
||||
out := AttachCacheControl(dst, src)
|
||||
if got := gjson.GetBytes(out, "cache_control.type").String(); got != "ephemeral" {
|
||||
t.Fatalf("cache_control.type = %q, want ephemeral; out=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "cache_control.ttl").String(); got != "5m" {
|
||||
t.Fatalf("cache_control.ttl = %q, want 5m; out=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCacheControl_IgnoresMissing(t *testing.T) {
|
||||
src := gjson.Parse(`{"text":"hi"}`)
|
||||
dst := []byte(`{"type":"text","text":"hi"}`)
|
||||
|
||||
out := AttachCacheControl(dst, src)
|
||||
if gjson.GetBytes(out, "cache_control").Exists() {
|
||||
t.Fatalf("cache_control should be absent; out=%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachMessageCacheControl_PromotesStringContent(t *testing.T) {
|
||||
src := gjson.Parse(`{"role":"user","content":"hi","cache_control":{"type":"ephemeral"}}`)
|
||||
msg := []byte(`{"role":"user","content":"hi"}`)
|
||||
|
||||
out := AttachMessageCacheControl(msg, src)
|
||||
if got := gjson.GetBytes(out, "content.0.type").String(); got != "text" {
|
||||
t.Fatalf("content.0.type = %q, want text; out=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "content.0.text").String(); got != "hi" {
|
||||
t.Fatalf("content.0.text = %q, want hi; out=%s", got, out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "content.0.cache_control.type").String(); got != "ephemeral" {
|
||||
t.Fatalf("content.0.cache_control.type = %q, want ephemeral; out=%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachMessageCacheControl_SkipsWhenLastPartHasCacheControl(t *testing.T) {
|
||||
src := gjson.Parse(`{"cache_control":{"type":"ephemeral","ttl":"1h"}}`)
|
||||
msg := []byte(`{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}`)
|
||||
|
||||
out := AttachMessageCacheControl(msg, src)
|
||||
if gjson.GetBytes(out, "content.0.cache_control.ttl").Exists() {
|
||||
t.Fatalf("part-level cache_control should win; out=%s", out)
|
||||
}
|
||||
}
|
||||
102
backend/internal/translator/common/claude_messages.go
Normal file
102
backend/internal/translator/common/claude_messages.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ClaudeMessageAccumulator groups consecutive Claude messages by role.
|
||||
type ClaudeMessageAccumulator struct {
|
||||
messages [][]byte
|
||||
role string
|
||||
content [][]byte
|
||||
toolUseParts [][]byte
|
||||
}
|
||||
|
||||
// NewClaudeMessageAccumulator creates an accumulator sized for the expected messages.
|
||||
func NewClaudeMessageAccumulator(capacity int) *ClaudeMessageAccumulator {
|
||||
return &ClaudeMessageAccumulator{
|
||||
messages: NewRawArrayItems(int64(capacity)),
|
||||
}
|
||||
}
|
||||
|
||||
// Append adds one Claude-shaped message to the current role turn.
|
||||
func (a *ClaudeMessageAccumulator) Append(message []byte) {
|
||||
if len(message) == 0 {
|
||||
return
|
||||
}
|
||||
root := gjson.ParseBytes(message)
|
||||
role := root.Get("role").String()
|
||||
if role != "user" && role != "assistant" {
|
||||
return
|
||||
}
|
||||
parts := claudeMessageContentParts(root.Get("content"))
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
if a.role != "" && a.role != role {
|
||||
a.Flush()
|
||||
}
|
||||
a.role = role
|
||||
for _, part := range parts {
|
||||
if role == "assistant" && gjson.GetBytes(part, "type").String() == "tool_use" {
|
||||
a.toolUseParts = append(a.toolUseParts, part)
|
||||
continue
|
||||
}
|
||||
a.content = append(a.content, part)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush closes the current role turn while keeping accumulated messages.
|
||||
func (a *ClaudeMessageAccumulator) Flush() {
|
||||
if a.role == "" {
|
||||
return
|
||||
}
|
||||
parts := a.content
|
||||
if len(a.toolUseParts) > 0 {
|
||||
combined := make([][]byte, 0, len(a.content)+len(a.toolUseParts))
|
||||
combined = append(combined, a.content...)
|
||||
combined = append(combined, a.toolUseParts...)
|
||||
parts = combined
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
message := []byte(`{"role":"","content":[]}`)
|
||||
message, _ = sjson.SetBytes(message, "role", a.role)
|
||||
message, _ = sjson.SetRawBytes(message, "content", JoinRawArray(parts))
|
||||
a.messages = append(a.messages, message)
|
||||
}
|
||||
a.role = ""
|
||||
a.content = nil
|
||||
a.toolUseParts = nil
|
||||
}
|
||||
|
||||
// Messages flushes the final turn and returns all accumulated messages.
|
||||
func (a *ClaudeMessageAccumulator) Messages() [][]byte {
|
||||
a.Flush()
|
||||
return a.messages
|
||||
}
|
||||
|
||||
func claudeMessageContentParts(content gjson.Result) [][]byte {
|
||||
if !content.Exists() || content.Type == gjson.Null {
|
||||
return nil
|
||||
}
|
||||
if content.Type == gjson.String {
|
||||
if content.String() == "" {
|
||||
return nil
|
||||
}
|
||||
part := []byte(`{"type":"text","text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", content.String())
|
||||
return [][]byte{part}
|
||||
}
|
||||
if !content.IsArray() {
|
||||
return nil
|
||||
}
|
||||
parts := make([][]byte, 0, len(content.Array()))
|
||||
content.ForEach(func(_, part gjson.Result) bool {
|
||||
if part.IsObject() {
|
||||
parts = append(parts, []byte(part.Raw))
|
||||
}
|
||||
return true
|
||||
})
|
||||
return parts
|
||||
}
|
||||
110
backend/internal/translator/common/claude_messages_test.go
Normal file
110
backend/internal/translator/common/claude_messages_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestClaudeMessageAccumulatorGroupsAndOrdersAssistantParts(t *testing.T) {
|
||||
accumulator := NewClaudeMessageAccumulator(3)
|
||||
accumulator.Append([]byte(`{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"first","input":{}}]}`))
|
||||
accumulator.Append([]byte(`{"role":"assistant","content":[{"type":"thinking","thinking":"reason"},{"type":"text","text":"answer"}]}`))
|
||||
accumulator.Append([]byte(`{"role":"assistant","content":[{"type":"tool_use","id":"call_2","name":"second","input":{}}]}`))
|
||||
|
||||
messages := accumulator.Messages()
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("message count = %d, want 1", len(messages))
|
||||
}
|
||||
content := gjson.GetBytes(messages[0], "content").Array()
|
||||
wantTypes := []string{"thinking", "text", "tool_use", "tool_use"}
|
||||
if len(content) != len(wantTypes) {
|
||||
t.Fatalf("content count = %d, want %d. Message: %s", len(content), len(wantTypes), string(messages[0]))
|
||||
}
|
||||
for i, wantType := range wantTypes {
|
||||
if got := content[i].Get("type").String(); got != wantType {
|
||||
t.Fatalf("content[%d].type = %q, want %q", i, got, wantType)
|
||||
}
|
||||
}
|
||||
if got := content[2].Get("id").String(); got != "call_1" {
|
||||
t.Fatalf("first tool_use id = %q, want call_1", got)
|
||||
}
|
||||
if got := content[3].Get("id").String(); got != "call_2" {
|
||||
t.Fatalf("second tool_use id = %q, want call_2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeMessageAccumulatorPreservesUserOrderAndRoleBoundaries(t *testing.T) {
|
||||
accumulator := NewClaudeMessageAccumulator(3)
|
||||
accumulator.Append([]byte(`{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"ok"}]}`))
|
||||
accumulator.Append([]byte(`{"role":"user","content":[{"type":"text","text":"continue"}]}`))
|
||||
accumulator.Append([]byte(`{"role":"assistant","content":[{"type":"text","text":"done"}]}`))
|
||||
|
||||
messages := accumulator.Messages()
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("message count = %d, want 2", len(messages))
|
||||
}
|
||||
if got := gjson.GetBytes(messages[0], "role").String(); got != "user" {
|
||||
t.Fatalf("messages[0].role = %q, want user", got)
|
||||
}
|
||||
if got := gjson.GetBytes(messages[0], "content.0.type").String(); got != "tool_result" {
|
||||
t.Fatalf("first user block type = %q, want tool_result", got)
|
||||
}
|
||||
if got := gjson.GetBytes(messages[0], "content.1.text").String(); got != "continue" {
|
||||
t.Fatalf("second user block text = %q, want continue", got)
|
||||
}
|
||||
if got := gjson.GetBytes(messages[1], "role").String(); got != "assistant" {
|
||||
t.Fatalf("messages[1].role = %q, want assistant", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeMessageAccumulatorSkipsEmptyMessagesWithoutBreakingTurn(t *testing.T) {
|
||||
accumulator := NewClaudeMessageAccumulator(3)
|
||||
accumulator.Append([]byte(`{"role":"assistant","content":[{"type":"text","text":"first"}]}`))
|
||||
accumulator.Append([]byte(`{"role":"user"}`))
|
||||
accumulator.Append([]byte(`{"role":"user","content":null}`))
|
||||
accumulator.Append([]byte(`{"role":"user","content":""}`))
|
||||
accumulator.Append([]byte(`{"role":"user","content":[]}`))
|
||||
accumulator.Append([]byte(`{"role":"invalid","content":[{"type":"text","text":"ignored"}]}`))
|
||||
accumulator.Append([]byte(`{"role":"assistant","content":[{"type":"text","text":"second"}]}`))
|
||||
|
||||
messages := accumulator.Messages()
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("message count = %d, want 1", len(messages))
|
||||
}
|
||||
if got := gjson.GetBytes(messages[0], "content.#").Int(); got != 2 {
|
||||
t.Fatalf("assistant content count = %d, want 2. Message: %s", got, string(messages[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeMessageAccumulatorFlushPreservesExplicitBoundary(t *testing.T) {
|
||||
accumulator := NewClaudeMessageAccumulator(2)
|
||||
accumulator.Append([]byte(`{"role":"user","content":"system reminder"}`))
|
||||
accumulator.Flush()
|
||||
accumulator.Append([]byte(`{"role":"user","content":[{"type":"text","text":"question"}]}`))
|
||||
|
||||
messages := accumulator.Messages()
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("message count = %d, want 2", len(messages))
|
||||
}
|
||||
if got := gjson.GetBytes(messages[0], "content.0.text").String(); got != "system reminder" {
|
||||
t.Fatalf("first message text = %q, want system reminder", got)
|
||||
}
|
||||
if got := gjson.GetBytes(messages[1], "content.0.text").String(); got != "question" {
|
||||
t.Fatalf("second message text = %q, want question", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeMessageAccumulatorPreservesBlockCacheControl(t *testing.T) {
|
||||
accumulator := NewClaudeMessageAccumulator(2)
|
||||
accumulator.Append([]byte(`{"role":"user","content":[{"type":"text","text":"cached","cache_control":{"type":"ephemeral"}}]}`))
|
||||
accumulator.Append([]byte(`{"role":"user","content":[{"type":"text","text":"fresh"}]}`))
|
||||
|
||||
messages := accumulator.Messages()
|
||||
if got := gjson.GetBytes(messages[0], "content.0.cache_control.type").String(); got != "ephemeral" {
|
||||
t.Fatalf("cache_control.type = %q, want ephemeral", got)
|
||||
}
|
||||
if gjson.GetBytes(messages[0], "content.1.cache_control").Exists() {
|
||||
t.Fatalf("second block should not have cache_control: %s", string(messages[0]))
|
||||
}
|
||||
}
|
||||
56
backend/internal/translator/common/claude_system.go
Normal file
56
backend/internal/translator/common/claude_system.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
claudeSystemReminderStart = "<system-reminder>"
|
||||
claudeSystemReminderEnd = "</system-reminder>"
|
||||
)
|
||||
|
||||
// ClaudeMessageSystemReminderText converts a Claude message-level system value
|
||||
// into ordinary user-visible reminder text for non-Claude upstream formats.
|
||||
func ClaudeMessageSystemReminderText(content gjson.Result) (string, bool) {
|
||||
parts := claudeSystemTextParts(content)
|
||||
if len(parts) == 0 {
|
||||
return "", false
|
||||
}
|
||||
text := strings.Join(parts, "\n")
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "", false
|
||||
}
|
||||
return claudeSystemReminderStart + "\n" + text + "\n" + claudeSystemReminderEnd, true
|
||||
}
|
||||
|
||||
func claudeSystemTextParts(content gjson.Result) []string {
|
||||
if !content.Exists() {
|
||||
return nil
|
||||
}
|
||||
if content.Type == gjson.String {
|
||||
text := content.String()
|
||||
if text == "" || util.IsClaudeCodeAttributionSystemText(text) {
|
||||
return nil
|
||||
}
|
||||
return []string{text}
|
||||
}
|
||||
if !content.IsArray() {
|
||||
return nil
|
||||
}
|
||||
parts := make([]string, 0)
|
||||
content.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Get("type").String() != "text" {
|
||||
return true
|
||||
}
|
||||
text := item.Get("text").String()
|
||||
if text == "" || util.IsClaudeCodeAttributionSystemText(text) {
|
||||
return true
|
||||
}
|
||||
parts = append(parts, text)
|
||||
return true
|
||||
})
|
||||
return parts
|
||||
}
|
||||
243
backend/internal/translator/common/claude_user_id.go
Normal file
243
backend/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
backend/internal/translator/common/claude_user_id_test.go
Normal file
286
backend/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)
|
||||
}
|
||||
}
|
||||
43
backend/internal/translator/common/file_data.go
Normal file
43
backend/internal/translator/common/file_data.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
)
|
||||
|
||||
// NormalizeOpenAIFileData returns the MIME type and raw base64 payload for OpenAI file content.
|
||||
func NormalizeOpenAIFileData(filename, fallbackMIMEType, fileData string) (mimeType, data string, ok bool) {
|
||||
if fileData == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
if fallbackMIMEType == "" {
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||
fallbackMIMEType = misc.MimeTypes[ext]
|
||||
}
|
||||
const dataURLPrefix = "data:"
|
||||
if len(fileData) < len(dataURLPrefix) || !strings.EqualFold(fileData[:len(dataURLPrefix)], dataURLPrefix) {
|
||||
if fallbackMIMEType == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return fallbackMIMEType, fileData, true
|
||||
}
|
||||
|
||||
metadata, payload, found := strings.Cut(fileData[len(dataURLPrefix):], ",")
|
||||
if !found || payload == "" {
|
||||
return "", "", false
|
||||
}
|
||||
fields := strings.Split(metadata, ";")
|
||||
mimeType = strings.TrimSpace(fields[0])
|
||||
if mimeType == "" {
|
||||
return "", "", false
|
||||
}
|
||||
for _, field := range fields[1:] {
|
||||
if strings.EqualFold(strings.TrimSpace(field), "base64") {
|
||||
return mimeType, payload, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
70
backend/internal/translator/common/file_data_test.go
Normal file
70
backend/internal/translator/common/file_data_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeOpenAIFileData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
fallbackMIME string
|
||||
fileData string
|
||||
wantMIMEType string
|
||||
wantData string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "data URL",
|
||||
filename: "test.pdf",
|
||||
fileData: "data:application/pdf;base64,JVBERi0xLjQK",
|
||||
wantMIMEType: "application/pdf",
|
||||
wantData: "JVBERi0xLjQK",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "data URL metadata and MIME override",
|
||||
filename: "test.txt",
|
||||
fileData: "data:application/pdf;charset=binary;BASE64,JVBERi0xLjQK",
|
||||
wantMIMEType: "application/pdf",
|
||||
wantData: "JVBERi0xLjQK",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "case-insensitive data URL scheme",
|
||||
filename: "test.pdf",
|
||||
fileData: "DATA:application/pdf;base64,JVBERi0xLjQK",
|
||||
wantMIMEType: "application/pdf",
|
||||
wantData: "JVBERi0xLjQK",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "raw base64",
|
||||
filename: "TEST.PDF",
|
||||
fileData: "JVBERi0xLjQK",
|
||||
wantMIMEType: "application/pdf",
|
||||
wantData: "JVBERi0xLjQK",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "raw base64 with explicit MIME type",
|
||||
fallbackMIME: "application/pdf",
|
||||
fileData: "JVBERi0xLjQK",
|
||||
wantMIMEType: "application/pdf",
|
||||
wantData: "JVBERi0xLjQK",
|
||||
wantOK: true,
|
||||
},
|
||||
{name: "empty data", filename: "test.pdf"},
|
||||
{name: "raw base64 without known extension", filename: "test", fileData: "JVBERi0xLjQK"},
|
||||
{name: "data URL without base64 marker", filename: "test.pdf", fileData: "data:application/pdf,JVBERi0xLjQK"},
|
||||
{name: "data URL without MIME type", filename: "test.pdf", fileData: "data:;base64,JVBERi0xLjQK"},
|
||||
{name: "data URL without payload", filename: "test.pdf", fileData: "data:application/pdf;base64,"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mimeType, data, ok := NormalizeOpenAIFileData(test.filename, test.fallbackMIME, test.fileData)
|
||||
if mimeType != test.wantMIMEType || data != test.wantData || ok != test.wantOK {
|
||||
t.Fatalf("NormalizeOpenAIFileData() = (%q, %q, %v), want (%q, %q, %v)", mimeType, data, ok, test.wantMIMEType, test.wantData, test.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
8
backend/internal/translator/common/gemini.go
Normal file
8
backend/internal/translator/common/gemini.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package common
|
||||
|
||||
import "github.com/tidwall/gjson"
|
||||
|
||||
// IsGeminiThoughtPart reports whether a Gemini part contains hidden model thought.
|
||||
func IsGeminiThoughtPart(part gjson.Result) bool {
|
||||
return part.Get("thought").Bool()
|
||||
}
|
||||
19
backend/internal/translator/common/interactions_usage.go
Normal file
19
backend/internal/translator/common/interactions_usage.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package common
|
||||
|
||||
import "github.com/tidwall/gjson"
|
||||
|
||||
func InteractionsUsage(root gjson.Result) gjson.Result {
|
||||
for _, path := range []string{
|
||||
"interaction.usage",
|
||||
"usage",
|
||||
"metadata.total_usage",
|
||||
"metadata.usage",
|
||||
"interaction.metadata.total_usage",
|
||||
"interaction.metadata.usage",
|
||||
} {
|
||||
if value := root.Get(path); value.Exists() {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return gjson.Result{}
|
||||
}
|
||||
61
backend/internal/translator/common/request.go
Normal file
61
backend/internal/translator/common/request.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const tooluLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// GenerateClaudeToolCallID generates a random tool use ID prefixed with toolu_
|
||||
// using rejection sampling to guarantee a uniform distribution across the 62 alphanumeric characters.
|
||||
func GenerateClaudeToolCallID() string {
|
||||
const maxValidByte = 256 - (256 % len(tooluLetters)) // 248: exact multiple of 62
|
||||
var b strings.Builder
|
||||
b.Grow(len("toolu_") + 24)
|
||||
b.WriteString("toolu_")
|
||||
|
||||
var buf [32]byte
|
||||
n := 0
|
||||
for n < 24 {
|
||||
_, _ = rand.Read(buf[:])
|
||||
for _, bVal := range buf {
|
||||
if int(bVal) < maxValidByte {
|
||||
b.WriteByte(tooluLetters[int(bVal)%len(tooluLetters)])
|
||||
n++
|
||||
if n == 24 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RequestModelName returns the model name from the original request, falling
|
||||
// back to the translated request when the original request is unavailable.
|
||||
func RequestModelName(originalRequestRawJSON, requestRawJSON []byte) string {
|
||||
for _, rawJSON := range [][]byte{originalRequestRawJSON, requestRawJSON} {
|
||||
if modelName := requestModelName(rawJSON); modelName != "" {
|
||||
return modelName
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requestModelName(rawJSON []byte) string {
|
||||
if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
|
||||
return ""
|
||||
}
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
for _, path := range []string{"model", "request.model"} {
|
||||
model := root.Get(path)
|
||||
if model.Type == gjson.String && strings.TrimSpace(model.String()) != "" {
|
||||
return model.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
35
backend/internal/translator/common/request_test.go
Normal file
35
backend/internal/translator/common/request_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRequestModelNamePrefersOriginalRequest(t *testing.T) {
|
||||
original := []byte(`{"model":"original-model"}`)
|
||||
translated := []byte(`{"model":"translated-model"}`)
|
||||
|
||||
if got := RequestModelName(original, translated); got != "original-model" {
|
||||
t.Fatalf("model = %q, want original-model", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestModelNameSupportsWrappedRequest(t *testing.T) {
|
||||
request := []byte(`{"request":{"model":"wrapped-model"}}`)
|
||||
|
||||
if got := RequestModelName(nil, request); got != "wrapped-model" {
|
||||
t.Fatalf("model = %q, want wrapped-model", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateClaudeToolCallID(t *testing.T) {
|
||||
id := GenerateClaudeToolCallID()
|
||||
if len(id) != 30 {
|
||||
t.Fatalf("expected len 30 (toolu_ + 24), got %d: %q", len(id), id)
|
||||
}
|
||||
if id[:6] != "toolu_" {
|
||||
t.Fatalf("expected prefix toolu_, got %q", id)
|
||||
}
|
||||
for _, ch := range id[6:] {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9')) {
|
||||
t.Fatalf("invalid character in ID %q: %c", id, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
20
backend/internal/translator/common/responses.go
Normal file
20
backend/internal/translator/common/responses.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package common
|
||||
|
||||
import "github.com/tidwall/sjson"
|
||||
|
||||
// SetResponsesToolCallIdentity writes a resolved Responses tool name and namespace.
|
||||
func SetResponsesToolCallIdentity(item []byte, name, namespace, itemPath string) []byte {
|
||||
namePath := "name"
|
||||
namespacePath := "namespace"
|
||||
if itemPath != "" {
|
||||
namePath = itemPath + ".name"
|
||||
namespacePath = itemPath + ".namespace"
|
||||
}
|
||||
item, _ = sjson.SetBytes(item, namePath, name)
|
||||
if namespace != "" {
|
||||
item, _ = sjson.SetBytes(item, namespacePath, namespace)
|
||||
} else {
|
||||
item, _ = sjson.DeleteBytes(item, namespacePath)
|
||||
}
|
||||
return item
|
||||
}
|
||||
70
backend/internal/translator/common/responses_test.go
Normal file
70
backend/internal/translator/common/responses_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSetResponsesToolCallIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
toolName string
|
||||
namespace string
|
||||
itemPath string
|
||||
namePath string
|
||||
namespacePath string
|
||||
wantName string
|
||||
wantNamespace string
|
||||
wantNamespaceExists bool
|
||||
}{
|
||||
{
|
||||
name: "top level",
|
||||
input: `{"name":"functions__exec"}`,
|
||||
toolName: "exec",
|
||||
namespace: "functions",
|
||||
namePath: "name",
|
||||
namespacePath: "namespace",
|
||||
wantName: "exec",
|
||||
wantNamespace: "functions",
|
||||
wantNamespaceExists: true,
|
||||
},
|
||||
{
|
||||
name: "nested item",
|
||||
input: `{"item":{"name":"functions__exec"}}`,
|
||||
toolName: "exec",
|
||||
namespace: "functions",
|
||||
itemPath: "item",
|
||||
namePath: "item.name",
|
||||
namespacePath: "item.namespace",
|
||||
wantName: "exec",
|
||||
wantNamespace: "functions",
|
||||
wantNamespaceExists: true,
|
||||
},
|
||||
{
|
||||
name: "remove stale namespace",
|
||||
input: `{"name":"old","namespace":"stale"}`,
|
||||
toolName: "plain",
|
||||
namePath: "name",
|
||||
namespacePath: "namespace",
|
||||
wantName: "plain",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := SetResponsesToolCallIdentity([]byte(test.input), test.toolName, test.namespace, test.itemPath)
|
||||
if actual := gjson.GetBytes(got, test.namePath).String(); actual != test.wantName {
|
||||
t.Fatalf("name = %q, want %q; output=%s", actual, test.wantName, got)
|
||||
}
|
||||
namespace := gjson.GetBytes(got, test.namespacePath)
|
||||
if namespace.Exists() != test.wantNamespaceExists {
|
||||
t.Fatalf("namespace exists = %t, want %t; output=%s", namespace.Exists(), test.wantNamespaceExists, got)
|
||||
}
|
||||
if test.wantNamespaceExists && namespace.String() != test.wantNamespace {
|
||||
t.Fatalf("namespace = %q, want %q; output=%s", namespace.String(), test.wantNamespace, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue