Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
606
backend/sdk/cliproxy/session/identity.go
Normal file
606
backend/sdk/cliproxy/session/identity.go
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
// Package session derives stable conversation identities from protocol request roots.
|
||||
package session
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
identityVersion = "cpa-session-root-v1"
|
||||
identityPrefix = "ctx:v1:"
|
||||
instructionRuneLimit = 50
|
||||
)
|
||||
|
||||
var legacyClaudeSessionPattern = regexp.MustCompile(`_session_([a-f0-9-]+)$`)
|
||||
|
||||
type canonicalRoot struct {
|
||||
Version string `json:"version"`
|
||||
Format string `json:"format"`
|
||||
CallerScope string `json:"caller_scope"`
|
||||
Instructions []string `json:"instructions,omitempty"`
|
||||
User []canonicalPart `json:"user,omitempty"`
|
||||
Resource string `json:"resource,omitempty"`
|
||||
}
|
||||
|
||||
type canonicalPart struct {
|
||||
Kind string `json:"kind"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// NormalizeExplicitID validates an explicit client-provided session identifier.
|
||||
// It preserves opaque printable values while rejecting oversized or control-bearing IDs.
|
||||
func NormalizeExplicitID(raw string) string {
|
||||
for _, r := range raw {
|
||||
if unicode.IsControl(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || len(raw) > 256 {
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ClaudeMetadataSessionID extracts the explicit Claude Code session from
|
||||
// current JSON metadata or the legacy user_id suffix before bounding the
|
||||
// surrounding metadata container.
|
||||
func ClaudeMetadataSessionID(payload []byte) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
userID := strings.TrimSpace(gjson.GetBytes(payload, "metadata.user_id").String())
|
||||
if userID == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(userID, "{") {
|
||||
return NormalizeExplicitID(gjson.Get(userID, "session_id").String())
|
||||
}
|
||||
if matches := legacyClaudeSessionPattern.FindStringSubmatch(userID); len(matches) >= 2 {
|
||||
return NormalizeExplicitID(matches[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CallerScope returns an irreversible namespace for a downstream caller credential.
|
||||
func CallerScope(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte("cli-proxy-api:caller-scope:v1\x00" + value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// DerivedID returns a derived session identity stored in execution metadata.
|
||||
func DerivedID(metadata map[string]any) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := metadata[cliproxyexecutor.DerivedSessionIDMetadataKey].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// Enrich derives a session identity once and places it in both request and option metadata.
|
||||
func Enrich(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Request, cliproxyexecutor.Options) {
|
||||
payload := opts.OriginalRequest
|
||||
if len(payload) == 0 && len(req.Payload) > 0 {
|
||||
opts.OriginalRequest = bytes.Clone(req.Payload)
|
||||
payload = opts.OriginalRequest
|
||||
}
|
||||
if executionID := firstNormalizedMetadataID(cliproxyexecutor.ExecutionSessionMetadataKey, opts.Metadata, req.Metadata); executionID != "" {
|
||||
req.Metadata = metadataWithValue(metadataWithoutKey(req.Metadata, cliproxyexecutor.DerivedSessionIDMetadataKey), cliproxyexecutor.ExecutionSessionMetadataKey, executionID)
|
||||
opts.Metadata = metadataWithValue(metadataWithoutKey(opts.Metadata, cliproxyexecutor.DerivedSessionIDMetadataKey), cliproxyexecutor.ExecutionSessionMetadataKey, executionID)
|
||||
return req, opts
|
||||
}
|
||||
req.Metadata = metadataWithoutKey(req.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey)
|
||||
opts.Metadata = metadataWithoutKey(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey)
|
||||
if hasExplicitSession(opts.Headers, payload) {
|
||||
req.Metadata = metadataWithoutKey(req.Metadata, cliproxyexecutor.DerivedSessionIDMetadataKey)
|
||||
opts.Metadata = metadataWithoutKey(opts.Metadata, cliproxyexecutor.DerivedSessionIDMetadataKey)
|
||||
return req, opts
|
||||
}
|
||||
|
||||
derivedID := firstNormalizedMetadataID(cliproxyexecutor.DerivedSessionIDMetadataKey, opts.Metadata, req.Metadata)
|
||||
req.Metadata = metadataWithoutKey(req.Metadata, cliproxyexecutor.DerivedSessionIDMetadataKey)
|
||||
opts.Metadata = metadataWithoutKey(opts.Metadata, cliproxyexecutor.DerivedSessionIDMetadataKey)
|
||||
if derivedID == "" {
|
||||
callerScope := metadataString(opts.Metadata, cliproxyexecutor.CallerScopeMetadataKey)
|
||||
if callerScope == "" {
|
||||
callerScope = metadataString(req.Metadata, cliproxyexecutor.CallerScopeMetadataKey)
|
||||
}
|
||||
derivedID = DeriveID(opts.SourceFormat, payload, callerScope)
|
||||
}
|
||||
if derivedID == "" {
|
||||
return req, opts
|
||||
}
|
||||
req.Metadata = metadataWithValue(req.Metadata, cliproxyexecutor.DerivedSessionIDMetadataKey, derivedID)
|
||||
opts.Metadata = metadataWithValue(opts.Metadata, cliproxyexecutor.DerivedSessionIDMetadataKey, derivedID)
|
||||
return req, opts
|
||||
}
|
||||
|
||||
func hasExplicitSession(headers map[string][]string, payload []byte) bool {
|
||||
for _, header := range []string{"X-Claude-Code-Session-Id", "X-Session-ID", "Session-Id", "Session_id", "X-Session-Affinity", "X-Client-Request-Id"} {
|
||||
if NormalizeExplicitID(headerValue(headers, header)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return false
|
||||
}
|
||||
// Parsing without copying matters here: this runs on every request and the
|
||||
// payload can be multiple megabytes.
|
||||
root := util.ParseGJSONBytesNoCopy(payload)
|
||||
for _, path := range []string{"session_id", "sessionId", "conversation_id", "prompt_cache_key"} {
|
||||
if NormalizeExplicitID(root.Get(path).String()) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if ClaudeMetadataSessionID(payload) != "" {
|
||||
return true
|
||||
}
|
||||
userID := strings.TrimSpace(root.Get("metadata.user_id").String())
|
||||
if NormalizeExplicitID(userID) != "" {
|
||||
return true
|
||||
}
|
||||
conversation := root.Get("conversation")
|
||||
if NormalizeExplicitID(conversation.Get("id").String()) != "" {
|
||||
return true
|
||||
}
|
||||
return conversation.Type == gjson.String && NormalizeExplicitID(conversation.String()) != ""
|
||||
}
|
||||
|
||||
func headerValue(headers map[string][]string, name string) string {
|
||||
for key, values := range headers {
|
||||
if !strings.EqualFold(key, name) {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
if normalized := NormalizeExplicitID(value); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DeriveID builds a stable identity from leading instructions and the first complete user input.
|
||||
func DeriveID(format sdktranslator.Format, payload []byte, callerScope string) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
var body map[string]any
|
||||
if errUnmarshal := json.Unmarshal(payload, &body); errUnmarshal != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
root := canonicalRoot{
|
||||
Version: identityVersion,
|
||||
Format: format.String(),
|
||||
CallerScope: strings.TrimSpace(callerScope),
|
||||
}
|
||||
if sourceFormatEqual(format, sdktranslator.FormatGemini) {
|
||||
root.Resource = stringField(body, "cachedContent", "cached_content")
|
||||
}
|
||||
|
||||
switch {
|
||||
case sourceFormatEqual(format, sdktranslator.FormatGemini):
|
||||
root.Instructions, root.User = geminiRoot(body)
|
||||
case sourceFormatEqual(format, sdktranslator.FormatInteractions):
|
||||
root.Instructions, root.User = interactionsRoot(body)
|
||||
case sourceFormatEqual(format, sdktranslator.FormatOpenAIResponse), sourceFormatEqual(format, sdktranslator.FormatCodex):
|
||||
root.Instructions, root.User = responsesRoot(body)
|
||||
case sourceFormatEqual(format, sdktranslator.FormatClaude):
|
||||
root.Instructions, root.User = messagesRoot(body, true)
|
||||
default:
|
||||
root.Instructions, root.User = messagesRoot(body, false)
|
||||
}
|
||||
if len(root.User) == 0 {
|
||||
return ""
|
||||
}
|
||||
return hashRoot(root)
|
||||
}
|
||||
|
||||
func messagesRoot(body map[string]any, includeTopLevelSystem bool) ([]string, []canonicalPart) {
|
||||
instructions := make([]string, 0)
|
||||
if includeTopLevelSystem {
|
||||
if system, ok := body["system"]; ok {
|
||||
instructions = appendInstruction(instructions, system)
|
||||
}
|
||||
}
|
||||
messages, _ := body["messages"].([]any)
|
||||
for _, rawMessage := range messages {
|
||||
message, ok := rawMessage.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
role := normalizedString(message["role"])
|
||||
switch role {
|
||||
case "system", "developer":
|
||||
instructions = appendInstruction(instructions, message["content"])
|
||||
case "user":
|
||||
return instructions, canonicalParts(message["content"])
|
||||
}
|
||||
}
|
||||
return instructions, nil
|
||||
}
|
||||
|
||||
func responsesRoot(body map[string]any) ([]string, []canonicalPart) {
|
||||
instructions := make([]string, 0)
|
||||
if value, ok := body["instructions"]; ok {
|
||||
instructions = appendInstruction(instructions, value)
|
||||
}
|
||||
input, ok := body["input"]
|
||||
if !ok {
|
||||
return instructions, nil
|
||||
}
|
||||
if inputString, okString := input.(string); okString {
|
||||
return instructions, canonicalParts(inputString)
|
||||
}
|
||||
items, _ := input.([]any)
|
||||
for _, rawItem := range items {
|
||||
item, okItem := rawItem.(map[string]any)
|
||||
if !okItem {
|
||||
continue
|
||||
}
|
||||
role := normalizedString(item["role"])
|
||||
switch role {
|
||||
case "system", "developer":
|
||||
instructions = appendInstruction(instructions, item["content"])
|
||||
case "user":
|
||||
return instructions, canonicalParts(item["content"])
|
||||
}
|
||||
}
|
||||
return instructions, nil
|
||||
}
|
||||
|
||||
func geminiRoot(body map[string]any) ([]string, []canonicalPart) {
|
||||
instructions := make([]string, 0)
|
||||
if value, ok := firstField(body, "systemInstruction", "system_instruction"); ok {
|
||||
instructions = appendInstruction(instructions, contentValue(value))
|
||||
}
|
||||
contents, _ := body["contents"].([]any)
|
||||
for _, rawContent := range contents {
|
||||
content, okContent := rawContent.(map[string]any)
|
||||
if !okContent || normalizedString(content["role"]) != "user" {
|
||||
continue
|
||||
}
|
||||
return instructions, canonicalParts(contentValue(content))
|
||||
}
|
||||
return instructions, nil
|
||||
}
|
||||
|
||||
func interactionsRoot(body map[string]any) ([]string, []canonicalPart) {
|
||||
instructions := make([]string, 0)
|
||||
if value, ok := firstField(body, "system_instruction", "systemInstruction"); ok {
|
||||
instructions = appendInstruction(instructions, contentValue(value))
|
||||
}
|
||||
input, ok := body["input"]
|
||||
if !ok {
|
||||
return instructions, nil
|
||||
}
|
||||
if inputString, okString := input.(string); okString {
|
||||
return instructions, canonicalParts(inputString)
|
||||
}
|
||||
for _, entry := range flattenInteractionEntries(input) {
|
||||
if text, okString := entry.(string); okString {
|
||||
return instructions, canonicalParts(text)
|
||||
}
|
||||
step, okStep := entry.(map[string]any)
|
||||
if !okStep {
|
||||
continue
|
||||
}
|
||||
role := normalizedString(step["role"])
|
||||
stepType := normalizedString(step["type"])
|
||||
if role == "system" || role == "developer" || stepType == "system_instruction" || stepType == "developer_instruction" {
|
||||
instructions = appendInstruction(instructions, contentValue(step))
|
||||
continue
|
||||
}
|
||||
if role == "user" || stepType == "user_input" || ((stepType == "message" || stepType == "") && role == "") {
|
||||
return instructions, canonicalParts(contentValue(step))
|
||||
}
|
||||
}
|
||||
return instructions, nil
|
||||
}
|
||||
|
||||
func flattenInteractionEntries(value any) []any {
|
||||
entries := make([]any, 0)
|
||||
var appendValue func(any, string)
|
||||
appendValue = func(current any, inheritedRole string) {
|
||||
switch typed := current.(type) {
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
appendValue(child, inheritedRole)
|
||||
}
|
||||
case map[string]any:
|
||||
role := normalizedString(typed["role"])
|
||||
if role == "" {
|
||||
role = inheritedRole
|
||||
}
|
||||
if steps, ok := typed["steps"].([]any); ok {
|
||||
for _, child := range steps {
|
||||
appendValue(child, role)
|
||||
}
|
||||
return
|
||||
}
|
||||
if role != "" && normalizedString(typed["role"]) == "" {
|
||||
cloned := make(map[string]any, len(typed)+1)
|
||||
for key, child := range typed {
|
||||
cloned[key] = child
|
||||
}
|
||||
cloned["role"] = role
|
||||
typed = cloned
|
||||
}
|
||||
entries = append(entries, typed)
|
||||
default:
|
||||
entries = append(entries, typed)
|
||||
}
|
||||
}
|
||||
appendValue(value, "")
|
||||
return entries
|
||||
}
|
||||
|
||||
func appendInstruction(instructions []string, value any) []string {
|
||||
parts := canonicalParts(value)
|
||||
var builder strings.Builder
|
||||
for _, part := range parts {
|
||||
if part.Kind != "text" || part.Value == "" {
|
||||
continue
|
||||
}
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
builder.WriteString(part.Value)
|
||||
}
|
||||
if builder.Len() == 0 {
|
||||
return instructions
|
||||
}
|
||||
return append(instructions, truncateRunes(builder.String(), instructionRuneLimit))
|
||||
}
|
||||
|
||||
func canonicalParts(value any) []canonicalPart {
|
||||
parts := make([]canonicalPart, 0)
|
||||
appendCanonicalParts(&parts, value)
|
||||
return parts
|
||||
}
|
||||
|
||||
func appendCanonicalParts(parts *[]canonicalPart, value any) {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return
|
||||
case string:
|
||||
if typed != "" {
|
||||
*parts = append(*parts, canonicalPart{Kind: "text", Value: typed})
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
appendCanonicalParts(parts, child)
|
||||
}
|
||||
case map[string]any:
|
||||
if text, ok := typed["text"].(string); ok {
|
||||
appendCanonicalParts(parts, text)
|
||||
return
|
||||
}
|
||||
if nested, ok := typed["content"]; ok {
|
||||
appendCanonicalParts(parts, nested)
|
||||
return
|
||||
}
|
||||
if nested, ok := typed["parts"]; ok {
|
||||
appendCanonicalParts(parts, nested)
|
||||
return
|
||||
}
|
||||
if imageURL, ok := typed["image_url"]; ok {
|
||||
appendMediaPart(parts, "image", imageURL, "")
|
||||
return
|
||||
}
|
||||
if inlineData, ok := firstField(typed, "inlineData", "inline_data"); ok {
|
||||
appendMediaPart(parts, "inline_data", inlineData, "")
|
||||
return
|
||||
}
|
||||
if fileData, ok := firstField(typed, "fileData", "file_data"); ok {
|
||||
appendMediaPart(parts, "file", fileData, "")
|
||||
return
|
||||
}
|
||||
if source, ok := typed["source"]; ok {
|
||||
appendMediaPart(parts, normalizedString(typed["type"]), source, normalizedString(typed["media_type"]))
|
||||
return
|
||||
}
|
||||
normalized := normalizeJSONValue(typed)
|
||||
encoded, errMarshal := json.Marshal(normalized)
|
||||
if errMarshal == nil && len(encoded) > 0 {
|
||||
*parts = append(*parts, canonicalPart{Kind: "json", Value: string(encoded)})
|
||||
}
|
||||
default:
|
||||
encoded, errMarshal := json.Marshal(typed)
|
||||
if errMarshal == nil && len(encoded) > 0 {
|
||||
*parts = append(*parts, canonicalPart{Kind: "json", Value: string(encoded)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendMediaPart(parts *[]canonicalPart, kind string, value any, fallbackMIME string) {
|
||||
kind = strings.TrimSpace(kind)
|
||||
if kind == "" {
|
||||
kind = "media"
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if typed != "" {
|
||||
*parts = append(*parts, canonicalPart{Kind: kind, MIME: fallbackMIME, Value: typed})
|
||||
}
|
||||
case map[string]any:
|
||||
mime := stringField(typed, "mimeType", "mime_type", "media_type")
|
||||
if mime == "" {
|
||||
mime = fallbackMIME
|
||||
}
|
||||
mediaValue := stringField(typed, "url", "uri", "fileUri", "file_uri", "data")
|
||||
if mediaValue != "" {
|
||||
*parts = append(*parts, canonicalPart{Kind: kind, MIME: mime, Value: mediaValue})
|
||||
}
|
||||
default:
|
||||
appendCanonicalParts(parts, typed)
|
||||
}
|
||||
}
|
||||
|
||||
func contentValue(value any) any {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
if content, exists := object["content"]; exists {
|
||||
return content
|
||||
}
|
||||
if parts, exists := object["parts"]; exists {
|
||||
return parts
|
||||
}
|
||||
if text, exists := object["text"]; exists {
|
||||
return text
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
func normalizeJSONValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
normalized := make(map[string]any, len(typed))
|
||||
for key, child := range typed {
|
||||
if strings.EqualFold(strings.TrimSpace(key), "cache_control") {
|
||||
continue
|
||||
}
|
||||
normalized[key] = normalizeJSONValue(child)
|
||||
}
|
||||
return normalized
|
||||
case []any:
|
||||
normalized := make([]any, len(typed))
|
||||
for index, child := range typed {
|
||||
normalized[index] = normalizeJSONValue(child)
|
||||
}
|
||||
return normalized
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func hashRoot(root canonicalRoot) string {
|
||||
encoded, errMarshal := json.Marshal(root)
|
||||
if errMarshal != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return identityPrefix + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func metadataWithValue(metadata map[string]any, key string, value any) map[string]any {
|
||||
cloned := make(map[string]any, len(metadata)+1)
|
||||
for existingKey, existingValue := range metadata {
|
||||
cloned[existingKey] = existingValue
|
||||
}
|
||||
cloned[key] = value
|
||||
return cloned
|
||||
}
|
||||
|
||||
func metadataWithoutKey(metadata map[string]any, key string) map[string]any {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
if _, exists := metadata[key]; !exists {
|
||||
return metadata
|
||||
}
|
||||
cloned := make(map[string]any, len(metadata)-1)
|
||||
for existingKey, existingValue := range metadata {
|
||||
if existingKey != key {
|
||||
cloned[existingKey] = existingValue
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func firstNormalizedMetadataID(key string, metadataSets ...map[string]any) string {
|
||||
for _, metadata := range metadataSets {
|
||||
if metadata == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := metadata[key].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if normalized := NormalizeExplicitID(raw); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstMetadataString(key string, metadataSets ...map[string]any) string {
|
||||
for _, metadata := range metadataSets {
|
||||
if value := metadataString(metadata, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func metadataString(metadata map[string]any, key string) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := metadata[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
if text, okText := value.(string); okText {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func firstField(object map[string]any, keys ...string) (any, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := object[key]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func stringField(object map[string]any, keys ...string) string {
|
||||
value, ok := firstField(object, keys...)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
text, _ := value.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func normalizedString(value any) string {
|
||||
text, _ := value.(string)
|
||||
return strings.ToLower(strings.TrimSpace(text))
|
||||
}
|
||||
|
||||
func truncateRunes(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
func sourceFormatEqual(left, right sdktranslator.Format) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(left.String()), strings.TrimSpace(right.String()))
|
||||
}
|
||||
348
backend/sdk/cliproxy/session/identity_test.go
Normal file
348
backend/sdk/cliproxy/session/identity_test.go
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||
)
|
||||
|
||||
func TestDeriveIDStableAcrossConversationGrowth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
format sdktranslator.Format
|
||||
first string
|
||||
later string
|
||||
}{
|
||||
{
|
||||
name: "openai chat",
|
||||
format: sdktranslator.FormatOpenAI,
|
||||
first: `{"messages":[{"role":"system","content":"system prompt"},{"role":"developer","content":"developer prompt"},{"role":"user","content":"complete first user prompt"}]}`,
|
||||
later: `{"messages":[{"role":"system","content":"system prompt"},{"role":"developer","content":"developer prompt"},{"role":"user","content":"complete first user prompt"},{"role":"assistant","content":"answer"},{"role":"developer","content":"later instruction"},{"role":"user","content":"next"}]}`,
|
||||
},
|
||||
{
|
||||
name: "claude messages",
|
||||
format: sdktranslator.FormatClaude,
|
||||
first: `{"system":[{"type":"text","text":"system prompt"}],"messages":[{"role":"user","content":[{"type":"text","text":"complete first user prompt"}]}]}`,
|
||||
later: `{"system":[{"type":"text","text":"system prompt"}],"messages":[{"role":"user","content":[{"type":"text","text":"complete first user prompt"}]},{"role":"assistant","content":"answer"},{"role":"user","content":"next"}]}`,
|
||||
},
|
||||
{
|
||||
name: "openai responses",
|
||||
format: sdktranslator.FormatOpenAIResponse,
|
||||
first: `{"instructions":"system prompt","input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":"developer prompt"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"complete first user prompt"}]}]}`,
|
||||
later: `{"instructions":"system prompt","input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":"developer prompt"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"complete first user prompt"}]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}]}`,
|
||||
},
|
||||
{
|
||||
name: "gemini",
|
||||
format: sdktranslator.FormatGemini,
|
||||
first: `{"systemInstruction":{"parts":[{"text":"system prompt"}]},"contents":[{"role":"user","parts":[{"text":"complete first user prompt"}]}]}`,
|
||||
later: `{"systemInstruction":{"parts":[{"text":"system prompt"}]},"contents":[{"role":"user","parts":[{"text":"complete first user prompt"}]},{"role":"model","parts":[{"text":"answer"}]},{"role":"user","parts":[{"text":"next"}]}]}`,
|
||||
},
|
||||
{
|
||||
name: "interactions",
|
||||
format: sdktranslator.FormatInteractions,
|
||||
first: `{"system_instruction":"system prompt","input":[{"type":"developer_instruction","text":"developer prompt"},{"type":"user_input","content":[{"type":"text","text":"complete first user prompt"}]}]}`,
|
||||
later: `{"system_instruction":"system prompt","input":[{"type":"developer_instruction","text":"developer prompt"},{"type":"user_input","content":[{"type":"text","text":"complete first user prompt"}]},{"type":"model_output","content":[{"type":"text","text":"answer"}]},{"type":"user_input","content":[{"type":"text","text":"next"}]}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
firstID := DeriveID(test.format, []byte(test.first), "caller-a")
|
||||
laterID := DeriveID(test.format, []byte(test.later), "caller-a")
|
||||
if firstID == "" {
|
||||
t.Fatal("DeriveID() returned empty")
|
||||
}
|
||||
if firstID != laterID {
|
||||
t.Fatalf("conversation growth changed identity: first=%q later=%q", firstID, laterID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveIDInstructionPrefixAndFullUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prefix := strings.Repeat("界", 50)
|
||||
first := []byte(`{"messages":[{"role":"system","content":"` + prefix + `timestamp-a"},{"role":"user","content":"` + strings.Repeat("u", 120) + `a"}]}`)
|
||||
sameRoot := []byte(`{"messages":[{"role":"system","content":"` + prefix + `timestamp-b"},{"role":"user","content":"` + strings.Repeat("u", 120) + `a"}]}`)
|
||||
differentUser := []byte(`{"messages":[{"role":"system","content":"` + prefix + `timestamp-b"},{"role":"user","content":"` + strings.Repeat("u", 120) + `b"}]}`)
|
||||
|
||||
firstID := DeriveID(sdktranslator.FormatOpenAI, first, "caller-a")
|
||||
if firstID == "" {
|
||||
t.Fatal("DeriveID() returned empty")
|
||||
}
|
||||
if got := DeriveID(sdktranslator.FormatOpenAI, sameRoot, "caller-a"); got != firstID {
|
||||
t.Fatalf("content after 50 Unicode characters changed identity: got=%q want=%q", got, firstID)
|
||||
}
|
||||
if got := DeriveID(sdktranslator.FormatOpenAI, differentUser, "caller-a"); got == firstID {
|
||||
t.Fatal("different full first user prompt produced the same identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveIDCallerIsolationAndGeminiCachedContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := []byte(`{"messages":[{"role":"user","content":"same prompt"}]}`)
|
||||
callerA := DeriveID(sdktranslator.FormatOpenAI, payload, CallerScope("api-key-a"))
|
||||
callerB := DeriveID(sdktranslator.FormatOpenAI, payload, CallerScope("api-key-b"))
|
||||
if callerA == "" || callerB == "" || callerA == callerB {
|
||||
t.Fatalf("caller isolation failed: callerA=%q callerB=%q", callerA, callerB)
|
||||
}
|
||||
|
||||
firstCached := []byte(`{"cachedContent":"cachedContents/abc","contents":[{"role":"user","parts":[{"text":"first"}]}]}`)
|
||||
grownCached := []byte(`{"cachedContent":"cachedContents/abc","contents":[{"role":"user","parts":[{"text":"first"}]},{"role":"model","parts":[{"text":"answer"}]},{"role":"user","parts":[{"text":"next"}]}]}`)
|
||||
differentCached := []byte(`{"cachedContent":"cachedContents/abc","contents":[{"role":"user","parts":[{"text":"different"}]}]}`)
|
||||
firstID := DeriveID(sdktranslator.FormatGemini, firstCached, "caller-a")
|
||||
grownID := DeriveID(sdktranslator.FormatGemini, grownCached, "caller-a")
|
||||
differentID := DeriveID(sdktranslator.FormatGemini, differentCached, "caller-a")
|
||||
if firstID == "" || firstID != grownID {
|
||||
t.Fatalf("cachedContent conversation growth changed identity: first=%q grown=%q", firstID, grownID)
|
||||
}
|
||||
if differentID == firstID {
|
||||
t.Fatalf("different first user prompts sharing cachedContent produced the same identity: %q", firstID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveIDRequiresFirstUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := []byte(`{"messages":[{"role":"system","content":"shared system"}]}`)
|
||||
if got := DeriveID(sdktranslator.FormatOpenAI, payload, "caller-a"); got != "" {
|
||||
t.Fatalf("DeriveID() = %q, want empty without first user", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichSkipsDerivationForExplicitSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
headers http.Header
|
||||
requestMetadata map[string]any
|
||||
optionMetadata map[string]any
|
||||
}{
|
||||
{
|
||||
name: "session header avoids malformed body parsing",
|
||||
payload: []byte(`not-json`),
|
||||
headers: http.Header{"X-Session-ID": []string{"header-session"}},
|
||||
},
|
||||
{
|
||||
name: "Claude Code session header",
|
||||
payload: []byte(`{"messages":[{"role":"user","content":"hello"}]}`),
|
||||
headers: http.Header{"X-Claude-Code-Session-Id": []string{"claude-session"}},
|
||||
},
|
||||
{
|
||||
name: "later valid multi-value session header",
|
||||
payload: []byte(`{"messages":[{"role":"user","content":"hello"}]}`),
|
||||
headers: http.Header{"X-Session-Affinity": []string{"", "later-valid-session"}},
|
||||
},
|
||||
{
|
||||
name: "OpenCode affinity header",
|
||||
payload: []byte(`{"messages":[{"role":"user","content":"hello"}]}`),
|
||||
headers: http.Header{"X-Session-Affinity": []string{"opencode-session"}},
|
||||
},
|
||||
{
|
||||
name: "Responses conversation object",
|
||||
payload: []byte(`{"conversation":{"id":"conversation-session"},"messages":[{"role":"user","content":"hello"}]}`),
|
||||
},
|
||||
{
|
||||
name: "Responses conversation string",
|
||||
payload: []byte(`{"conversation":"conversation-session","messages":[{"role":"user","content":"hello"}]}`),
|
||||
},
|
||||
{
|
||||
name: "metadata user id",
|
||||
payload: []byte(`{"metadata":{"user_id":"explicit-user"},"messages":[{"role":"user","content":"hello"}]}`),
|
||||
},
|
||||
{
|
||||
name: "long legacy Claude metadata session",
|
||||
payload: []byte(`{"metadata":{"user_id":"` + strings.Repeat("x", 300) +
|
||||
`_session_ac980658-63bd-4fb3-97ba-8da64cb1e344"},"messages":[{"role":"user","content":"hello"}]}`),
|
||||
},
|
||||
{
|
||||
name: "JSON metadata user id without nested session",
|
||||
payload: []byte(`{"metadata":{"user_id":"{\"device_id\":\"abc123\"}"},"messages":[{"role":"user","content":"hello"}]}`),
|
||||
},
|
||||
{
|
||||
name: "body session id",
|
||||
payload: []byte(`{"session_id":"body-session","messages":[{"role":"user","content":"hello"}]}`),
|
||||
},
|
||||
{
|
||||
name: "prompt cache key",
|
||||
payload: []byte(`{"prompt_cache_key":"cache-session","input":"hello"}`),
|
||||
},
|
||||
{
|
||||
name: "execution session option metadata",
|
||||
payload: []byte(`{"messages":[{"role":"user","content":"hello"}]}`),
|
||||
optionMetadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "execution-session"},
|
||||
},
|
||||
{
|
||||
name: "execution session request metadata",
|
||||
payload: []byte(`{"messages":[{"role":"user","content":"hello"}]}`),
|
||||
requestMetadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "execution-session"},
|
||||
},
|
||||
{
|
||||
name: "explicit header removes stale derived identity",
|
||||
payload: []byte(`{"messages":[{"role":"user","content":"hello"}]}`),
|
||||
headers: http.Header{"x-session-id": []string{"header-session"}},
|
||||
optionMetadata: map[string]any{
|
||||
cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:stale",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := cliproxyexecutor.Request{Payload: test.payload, Metadata: test.requestMetadata}
|
||||
opts := cliproxyexecutor.Options{
|
||||
OriginalRequest: test.payload,
|
||||
SourceFormat: sdktranslator.FormatOpenAI,
|
||||
Headers: test.headers,
|
||||
Metadata: test.optionMetadata,
|
||||
}
|
||||
enrichedReq, enrichedOpts := Enrich(req, opts)
|
||||
if got := DerivedID(enrichedReq.Metadata); got != "" {
|
||||
t.Fatalf("request DerivedSessionID = %q, want empty", got)
|
||||
}
|
||||
if got := DerivedID(enrichedOpts.Metadata); got != "" {
|
||||
t.Fatalf("options DerivedSessionID = %q, want empty", got)
|
||||
}
|
||||
if test.name == "execution session option metadata" || test.name == "execution session request metadata" {
|
||||
if got := metadataString(enrichedReq.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); got != "execution-session" {
|
||||
t.Fatalf("request execution session = %q, want execution-session", got)
|
||||
}
|
||||
if got := metadataString(enrichedOpts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); got != "execution-session" {
|
||||
t.Fatalf("options execution session = %q, want execution-session", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichDerivesAfterInvalidSessionIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseMessages := `"input":"hello"`
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
headers http.Header
|
||||
requestMetadata map[string]any
|
||||
optionMetadata map[string]any
|
||||
}{
|
||||
{
|
||||
name: "oversized prompt cache key",
|
||||
payload: []byte(`{"prompt_cache_key":"` + strings.Repeat("x", 257) + `",` + baseMessages + `}`),
|
||||
},
|
||||
{
|
||||
name: "trailing control character prompt cache key",
|
||||
payload: []byte(`{"prompt_cache_key":"tenant\n",` + baseMessages + `}`),
|
||||
},
|
||||
{
|
||||
name: "leading control character prompt cache key",
|
||||
payload: []byte(`{"prompt_cache_key":"\ttenant",` + baseMessages + `}`),
|
||||
},
|
||||
{
|
||||
name: "control character session header",
|
||||
payload: []byte(`{` + baseMessages + `}`),
|
||||
headers: http.Header{"X-Session-Affinity": []string{"bad\nsession"}},
|
||||
},
|
||||
{
|
||||
name: "oversized execution session option metadata",
|
||||
payload: []byte(`{"input":"hello"}`),
|
||||
optionMetadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: strings.Repeat("x", 257)},
|
||||
},
|
||||
{
|
||||
name: "control character execution session request metadata",
|
||||
payload: []byte(`{"input":"hello"}`),
|
||||
requestMetadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "bad\nsession"},
|
||||
},
|
||||
{
|
||||
name: "oversized retained derived session option metadata",
|
||||
payload: []byte(`{"input":"hello"}`),
|
||||
optionMetadata: map[string]any{cliproxyexecutor.DerivedSessionIDMetadataKey: strings.Repeat("x", 257)},
|
||||
},
|
||||
{
|
||||
name: "control character retained derived session request metadata",
|
||||
payload: []byte(`{"input":"hello"}`),
|
||||
requestMetadata: map[string]any{cliproxyexecutor.DerivedSessionIDMetadataKey: "bad\nsession"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := cliproxyexecutor.Request{Payload: test.payload, Metadata: test.requestMetadata}
|
||||
opts := cliproxyexecutor.Options{
|
||||
OriginalRequest: test.payload,
|
||||
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
||||
Headers: test.headers,
|
||||
Metadata: test.optionMetadata,
|
||||
}
|
||||
enrichedReq, enrichedOpts := Enrich(req, opts)
|
||||
requestID := DerivedID(enrichedReq.Metadata)
|
||||
optionsID := DerivedID(enrichedOpts.Metadata)
|
||||
wantID := DeriveID(sdktranslator.FormatOpenAIResponse, test.payload, "")
|
||||
if requestID != wantID || optionsID != wantID {
|
||||
t.Fatalf("derived identities = request:%q options:%q, want %q", requestID, optionsID, wantID)
|
||||
}
|
||||
if got := metadataString(enrichedReq.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); got != "" {
|
||||
t.Fatalf("request execution session = %q, want invalid value removed", got)
|
||||
}
|
||||
if got := metadataString(enrichedOpts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); got != "" {
|
||||
t.Fatalf("options execution session = %q, want invalid value removed", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichCopiesDerivedIdentityToRequestAndOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := cliproxyexecutor.Request{Payload: []byte(`{"messages":[{"role":"user","content":"hello"}]}`)}
|
||||
opts := cliproxyexecutor.Options{
|
||||
OriginalRequest: req.Payload,
|
||||
SourceFormat: sdktranslator.FormatOpenAI,
|
||||
Metadata: map[string]any{cliproxyexecutor.CallerScopeMetadataKey: "caller-a"},
|
||||
}
|
||||
|
||||
enrichedReq, enrichedOpts := Enrich(req, opts)
|
||||
reqID := DerivedID(enrichedReq.Metadata)
|
||||
optsID := DerivedID(enrichedOpts.Metadata)
|
||||
if reqID == "" || reqID != optsID {
|
||||
t.Fatalf("derived metadata mismatch: request=%q options=%q", reqID, optsID)
|
||||
}
|
||||
if _, exists := req.Metadata[cliproxyexecutor.DerivedSessionIDMetadataKey]; exists {
|
||||
t.Fatal("Enrich() mutated original request metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichCarriesRequestPayloadIntoSelectionOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := []byte(`{"conversation":{"id":"request-only-conversation"},"input":"hello"}`)
|
||||
_, enrichedOpts := Enrich(
|
||||
cliproxyexecutor.Request{Payload: payload},
|
||||
cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse},
|
||||
)
|
||||
|
||||
if !bytes.Equal(enrichedOpts.OriginalRequest, payload) {
|
||||
t.Fatalf("OriginalRequest = %q, want request payload %q", enrichedOpts.OriginalRequest, payload)
|
||||
}
|
||||
if len(enrichedOpts.OriginalRequest) > 0 && &enrichedOpts.OriginalRequest[0] == &payload[0] {
|
||||
t.Fatal("OriginalRequest aliases Request.Payload instead of preserving a snapshot")
|
||||
}
|
||||
if got := DerivedID(enrichedOpts.Metadata); got != "" {
|
||||
t.Fatalf("DerivedSessionID = %q, want explicit conversation to remain authoritative", got)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue