Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View 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
}

View 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)
}
})
}
}

View 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
}

View 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)
}
}

View 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
}

View 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]))
}
}

View 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
}

View file

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

View file

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

View 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
}

View 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)
}
})
}
}

View 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()
}

View 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{}
}

View 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 ""
}

View 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)
}
}
}

View 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
}

View 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)
}
})
}
}