diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 2848a1dbd..a804608c6 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -854,7 +854,7 @@ attemptLoop: } } - httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, false, opts.Alt, baseURL) + httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, false, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata)) if errReq != nil { err = errReq return resp, err @@ -1076,7 +1076,7 @@ attemptLoop: return resp, err } } - httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL) + httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata)) if errReq != nil { err = errReq return resp, err @@ -1568,7 +1568,7 @@ attemptLoop: return nil, err } } - httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL) + httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata)) if errReq != nil { err = errReq return nil, err @@ -2376,7 +2376,7 @@ func (e *AntigravityExecutor) updateAntigravityCreditsBalance(ctx context.Contex } } -func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string) (*http.Request, error) { +func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string, derivedSessionIDs ...string) (*http.Request, error) { if token == "" { return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"} } @@ -2408,7 +2408,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau if errProject != nil { return nil, errProject } - payload = geminiToAntigravity(modelName, payload, projectID) + payload = geminiToAntigravity(modelName, payload, projectID, derivedSessionIDs...) // Cap maxOutputTokens to model's max_completion_tokens from registry if maxOut := gjson.GetBytes(payload, "request.generationConfig.maxOutputTokens"); maxOut.Exists() && maxOut.Type == gjson.Number { @@ -3052,7 +3052,7 @@ func resolveCustomAntigravityBaseURL(auth *cliproxyauth.Auth) string { return "" } -func geminiToAntigravity(modelName string, payload []byte, projectID string) []byte { +func geminiToAntigravity(modelName string, payload []byte, projectID string, derivedSessionIDs ...string) []byte { template := payload template = helps.SetStringIfDifferent(template, "model", modelName) template = helps.SetStringIfDifferent(template, "userAgent", "antigravity") @@ -3078,7 +3078,14 @@ func geminiToAntigravity(modelName string, payload []byte, projectID string) []b template, _ = sjson.SetBytes(template, "requestId", generateImageGenRequestID()) } else if reqType != "web_search" { template, _ = sjson.SetBytes(template, "requestId", generateRequestID()) - template, _ = sjson.SetBytes(template, "request.sessionId", generateStableSessionID(payload)) + sessionID := strings.TrimSpace(gjson.GetBytes(template, "request.sessionId").String()) + if sessionID == "" && len(derivedSessionIDs) > 0 { + sessionID = strings.TrimSpace(derivedSessionIDs[0]) + } + if sessionID == "" { + sessionID = generateStableSessionID(payload) + } + template, _ = sjson.SetBytes(template, "request.sessionId", sessionID) } template, _ = sjson.DeleteBytes(template, "request.safetySettings") diff --git a/internal/runtime/executor/antigravity_executor_buildrequest_test.go b/internal/runtime/executor/antigravity_executor_buildrequest_test.go index b5329d789..66390cba3 100644 --- a/internal/runtime/executor/antigravity_executor_buildrequest_test.go +++ b/internal/runtime/executor/antigravity_executor_buildrequest_test.go @@ -130,6 +130,40 @@ func TestAntigravityBuildRequest_UsesRouteModelWhenPayloadContainsDifferentModel } } +func TestAntigravityBuildRequestUsesDerivedSessionIDAndPreservesExplicit(t *testing.T) { + t.Parallel() + + executor := &AntigravityExecutor{} + auth := &cliproxyauth.Auth{Metadata: map[string]any{"project_id": "project-1"}} + payload := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}}`) + req, err := executor.buildRequest(context.Background(), auth, "token", "gemini-3.1-pro", payload, false, "", "https://example.com", "-123456789") + if err != nil { + t.Fatalf("buildRequest error: %v", err) + } + body := requestBody(t, req) + request, ok := body["request"].(map[string]any) + if !ok { + t.Fatalf("request missing or invalid: %v", body["request"]) + } + if got := request["sessionId"]; got != "-123456789" { + t.Fatalf("request.sessionId = %v, want -123456789", got) + } + + explicitPayload := []byte(`{"request":{"sessionId":"-987654321","contents":[{"role":"user","parts":[{"text":"hello"}]}]}}`) + explicitReq, errExplicit := executor.buildRequest(context.Background(), auth, "token", "gemini-3.1-pro", explicitPayload, false, "", "https://example.com", "-123456789") + if errExplicit != nil { + t.Fatalf("buildRequest explicit error: %v", errExplicit) + } + explicitBody := requestBody(t, explicitReq) + explicitRequest, ok := explicitBody["request"].(map[string]any) + if !ok { + t.Fatalf("explicit request missing or invalid: %v", explicitBody["request"]) + } + if got := explicitRequest["sessionId"]; got != "-987654321" { + t.Fatalf("explicit request.sessionId = %v, want -987654321", got) + } +} + func TestAntigravityBuildRequest_PreservesIndependentWebSearchRequestType(t *testing.T) { body := buildRequestBodyFromRawPayload(t, "gemini-3.1-flash-lite", []byte(`{ "requestType": "web_search", diff --git a/internal/runtime/executor/antigravity_reasoning_replay.go b/internal/runtime/executor/antigravity_reasoning_replay.go index 063ef8000..9619a239a 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay.go +++ b/internal/runtime/executor/antigravity_reasoning_replay.go @@ -98,6 +98,9 @@ func antigravityReasoningReplayClientSessionKey(ctx context.Context, req cliprox return "prompt-cache:" + value } } + if value := helps.DerivedSessionID(opts.Metadata, req.Metadata); value != "" { + return "derived:" + value + } return "" } diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 1295d1b43..3f2eb7e56 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -1812,9 +1812,20 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form cache.ID = promptCacheKey.String() } } else if sourceFormatEqual(from, sdktranslator.FormatOpenAI) { - if apiKey := strings.TrimSpace(helps.APIKeyFromContext(ctx)); apiKey != "" { - cache.ID = uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+apiKey)).String() + if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() { + cache.ID = strings.TrimSpace(promptCacheKey.String()) } + if cache.ID == "" { + cache.ID = helps.ProviderSessionUUID("codex", req.Metadata) + } + if cache.ID == "" { + if apiKey := strings.TrimSpace(helps.APIKeyFromContext(ctx)); apiKey != "" { + cache.ID = uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+apiKey)).String() + } + } + } + if cache.ID == "" { + cache.ID = helps.ProviderSessionUUID("codex", req.Metadata) } if cache.ID != "" { diff --git a/internal/runtime/executor/codex_executor_cache_test.go b/internal/runtime/executor/codex_executor_cache_test.go index c0b7523b4..8d9713828 100644 --- a/internal/runtime/executor/codex_executor_cache_test.go +++ b/internal/runtime/executor/codex_executor_cache_test.go @@ -70,6 +70,32 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom } } +func TestCodexExecutorCacheHelper_UsesDerivedSessionUUID(t *testing.T) { + t.Parallel() + + executor := &CodexExecutor{} + req := cliproxyexecutor.Request{ + Model: "gpt-5.4", + Payload: []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}]}`), + Metadata: map[string]any{cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:derived-root"}, + } + expectedKey := helps.DerivedSessionUUID("codex", req.Metadata) + + httpReq, body, _, err := executor.cacheHelper(context.Background(), sdktranslator.FormatOpenAI, "https://example.com/responses", nil, req, req.Payload, []byte(`{"model":"gpt-5.4","stream":true}`)) + if err != nil { + t.Fatalf("cacheHelper error: %v", err) + } + if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != expectedKey { + t.Fatalf("prompt_cache_key = %q, want %q", got, expectedKey) + } + if got := httpReq.Header.Get("Session_id"); got != expectedKey { + t.Fatalf("Session_id = %q, want %q", got, expectedKey) + } + if _, errParse := uuid.Parse(expectedKey); errParse != nil { + t.Fatalf("derived prompt cache key %q is not a UUID: %v", expectedKey, errParse) + } +} + func TestCodexExecutorCacheHelper_ClaudeUsesClaudeCodeSessionID(t *testing.T) { executor := &CodexExecutor{} ctx := context.Background() diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 68f937049..31696bd58 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -1401,6 +1401,9 @@ func applyCodexPromptCacheHeadersWithContext(ctx context.Context, from sdktransl cache.ID = promptCacheKey.String() } } + if cache.ID == "" { + cache.ID = helps.ProviderSessionUUID("codex", req.Metadata) + } if cache.ID != "" { rawJSON = helps.SetStringIfDifferent(rawJSON, "prompt_cache_key", cache.ID) diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index d4ed9335e..17bbdcbea 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/gorilla/websocket" internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" @@ -1208,6 +1209,55 @@ func TestApplyCodexPromptCacheHeadersSetsSessionIDAndLegacyConversation(t *testi } } +func TestApplyCodexPromptCacheHeadersUsesDerivedSessionUUID(t *testing.T) { + t.Parallel() + + req := cliproxyexecutor.Request{ + Model: "gpt-5-codex", + Payload: []byte(`{"input":"hello"}`), + Metadata: map[string]any{cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:derived-root"}, + } + body, headers := applyCodexPromptCacheHeaders(sdktranslator.FormatInteractions, req, []byte(`{"model":"gpt-5-codex"}`)) + cacheKey := gjson.GetBytes(body, "prompt_cache_key").String() + if _, errParse := uuid.Parse(cacheKey); errParse != nil { + t.Fatalf("prompt_cache_key %q is not a UUID: %v", cacheKey, errParse) + } + if got := headers["session_id"]; len(got) != 1 || got[0] != cacheKey { + t.Fatalf("session_id = %#v, want [%q]", got, cacheKey) + } + if got := headers.Get("Conversation_id"); got != cacheKey { + t.Fatalf("Conversation_id = %q, want %q", got, cacheKey) + } +} + +func TestApplyCodexPromptCacheHeadersKeepsExecutionSessionAcrossIncrementalRoots(t *testing.T) { + t.Parallel() + + firstReq := cliproxyexecutor.Request{ + Model: "gpt-5-codex", + Payload: []byte(`{"input":"first"}`), + Metadata: map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "connection-1", + cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:first-root", + }, + } + secondReq := cliproxyexecutor.Request{ + Model: "gpt-5-codex", + Payload: []byte(`{"input":"second"}`), + Metadata: map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "connection-1", + cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:second-root", + }, + } + firstBody, _ := applyCodexPromptCacheHeaders(sdktranslator.FormatOpenAIResponse, firstReq, []byte(`{"model":"gpt-5-codex"}`)) + secondBody, _ := applyCodexPromptCacheHeaders(sdktranslator.FormatOpenAIResponse, secondReq, []byte(`{"model":"gpt-5-codex"}`)) + firstKey := gjson.GetBytes(firstBody, "prompt_cache_key").String() + secondKey := gjson.GetBytes(secondBody, "prompt_cache_key").String() + if firstKey == "" || firstKey != secondKey { + t.Fatalf("incremental websocket roots changed prompt cache key: first=%q second=%q", firstKey, secondKey) + } +} + func TestApplyCodexPromptCacheHeadersClaudeUsesClaudeCodeSessionID(t *testing.T) { firstReq := cliproxyexecutor.Request{ Model: "gpt-5-codex-claude-ws-cache-session", diff --git a/internal/runtime/executor/helps/derived_session.go b/internal/runtime/executor/helps/derived_session.go new file mode 100644 index 000000000..8e33c9bb4 --- /dev/null +++ b/internal/runtime/executor/helps/derived_session.go @@ -0,0 +1,66 @@ +package helps + +import ( + "crypto/sha256" + "encoding/binary" + "strconv" + "strings" + + "github.com/google/uuid" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + cliproxysession "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/session" +) + +// DerivedSessionID returns the first context-derived session identity in metadata order. +func DerivedSessionID(metadataSets ...map[string]any) string { + for _, metadata := range metadataSets { + if derivedID := cliproxysession.DerivedID(metadata); derivedID != "" { + return derivedID + } + } + return "" +} + +// DerivedSessionUUID maps a derived session identity to a provider-scoped stable UUID. +func DerivedSessionUUID(provider string, metadataSets ...map[string]any) string { + return stableProviderSessionUUID(provider, "derived-session", DerivedSessionID(metadataSets...)) +} + +// ProviderSessionUUID prefers a long-lived execution session and falls back to the derived identity. +func ProviderSessionUUID(provider string, metadataSets ...map[string]any) string { + for _, metadata := range metadataSets { + if executionID := metadataString(metadata, cliproxyexecutor.ExecutionSessionMetadataKey); executionID != "" { + return stableProviderSessionUUID(provider, "execution-session", executionID) + } + } + return DerivedSessionUUID(provider, metadataSets...) +} + +func stableProviderSessionUUID(provider string, kind string, identityValue string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + identityValue = strings.TrimSpace(identityValue) + if provider == "" || identityValue == "" { + return "" + } + identity := strings.Join([]string{"cli-proxy-api", provider, kind, identityValue}, "\x00") + return uuid.NewSHA1(uuid.NameSpaceOID, []byte(identity)).String() +} + +// DerivedAntigravitySessionID maps a derived session identity to Antigravity's negative decimal format. +func DerivedAntigravitySessionID(metadataSets ...map[string]any) string { + derivedID := DerivedSessionID(metadataSets...) + if derivedID == "" { + return "" + } + sum := sha256.Sum256([]byte("cli-proxy-api:antigravity:derived-session\x00" + derivedID)) + value := int64(binary.BigEndian.Uint64(sum[:8])) & 0x7FFFFFFFFFFFFFFF + return "-" + strconv.FormatInt(value, 10) +} + +func metadataString(metadata map[string]any, key string) string { + if metadata == nil { + return "" + } + value, _ := metadata[key].(string) + return strings.TrimSpace(value) +} diff --git a/internal/runtime/executor/helps/derived_session_test.go b/internal/runtime/executor/helps/derived_session_test.go new file mode 100644 index 000000000..9c899d104 --- /dev/null +++ b/internal/runtime/executor/helps/derived_session_test.go @@ -0,0 +1,69 @@ +package helps + +import ( + "regexp" + "testing" + + "github.com/google/uuid" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" +) + +func TestDerivedSessionProviderMappings(t *testing.T) { + t.Parallel() + + metadata := map[string]any{cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:test-root"} + codexID := DerivedSessionUUID("codex", metadata) + xaiID := DerivedSessionUUID("xai", metadata) + if _, errParse := uuid.Parse(codexID); errParse != nil { + t.Fatalf("Codex mapping %q is not a UUID: %v", codexID, errParse) + } + if _, errParse := uuid.Parse(xaiID); errParse != nil { + t.Fatalf("xAI mapping %q is not a UUID: %v", xaiID, errParse) + } + if codexID == xaiID { + t.Fatalf("provider namespaces produced the same UUID: %q", codexID) + } + if repeated := DerivedSessionUUID("codex", metadata); repeated != codexID { + t.Fatalf("Codex mapping is not stable: first=%q repeated=%q", codexID, repeated) + } + + antigravityID := DerivedAntigravitySessionID(metadata) + if matched := regexp.MustCompile(`^-[0-9]+$`).MatchString(antigravityID); !matched { + t.Fatalf("Antigravity mapping = %q, want negative decimal", antigravityID) + } + if repeated := DerivedAntigravitySessionID(metadata); repeated != antigravityID { + t.Fatalf("Antigravity mapping is not stable: first=%q repeated=%q", antigravityID, repeated) + } +} + +func TestProviderSessionUUIDPrefersExecutionSession(t *testing.T) { + t.Parallel() + + first := map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "connection-1", + cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:first-root", + } + second := map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "connection-1", + cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:second-root", + } + firstID := ProviderSessionUUID("codex", first) + secondID := ProviderSessionUUID("codex", second) + if firstID == "" || firstID != secondID { + t.Fatalf("execution session did not stabilize provider UUID: first=%q second=%q", firstID, secondID) + } + if firstID == DerivedSessionUUID("codex", first) { + t.Fatalf("provider UUID did not prefer execution session: %q", firstID) + } +} + +func TestDerivedSessionProviderMappingsRequireIdentity(t *testing.T) { + t.Parallel() + + if got := DerivedSessionUUID("codex", nil); got != "" { + t.Fatalf("DerivedSessionUUID() = %q, want empty", got) + } + if got := DerivedAntigravitySessionID(nil); got != "" { + t.Fatalf("DerivedAntigravitySessionID() = %q, want empty", got) + } +} diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index 89efeed9c..0ff3ea491 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -1293,9 +1293,11 @@ func xaiExecutionSessionID(req cliproxyexecutor.Request, opts cliproxyexecutor.O return value } if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() { - return strings.TrimSpace(promptCacheKey.String()) + if value := strings.TrimSpace(promptCacheKey.String()); value != "" { + return value + } } - return "" + return helps.DerivedSessionUUID("xai", opts.Metadata, req.Metadata) } func xaiRequiresIsolatedConversation(model string) bool { diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 09ca38d27..6737f57de 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -1758,6 +1758,31 @@ func TestXAIExecutorComposerSessionIsolation(t *testing.T) { } } +func TestXAIExecutionSessionIDUsesDerivedStableUUID(t *testing.T) { + t.Parallel() + + metadata := map[string]any{cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:derived-root"} + req := cliproxyexecutor.Request{Metadata: metadata, Payload: []byte(`{"input":"hello"}`)} + first := xaiExecutionSessionID(req, cliproxyexecutor.Options{}) + second := xaiExecutionSessionID(req, cliproxyexecutor.Options{}) + if first == "" || first != second { + t.Fatalf("derived xAI session is not stable: first=%q second=%q", first, second) + } + if _, errParse := uuid.Parse(first); errParse != nil { + t.Fatalf("derived xAI session %q is not a UUID: %v", first, errParse) + } + + req.Payload = []byte(`{"prompt_cache_key":"client-session","input":"hello"}`) + if got := xaiExecutionSessionID(req, cliproxyexecutor.Options{}); got != "client-session" { + t.Fatalf("explicit prompt_cache_key = %q, want client-session", got) + } + + req.Payload = []byte(`{"prompt_cache_key":" ","input":"hello"}`) + if got := xaiExecutionSessionID(req, cliproxyexecutor.Options{}); got != first { + t.Fatalf("blank prompt_cache_key session = %q, want derived UUID %q", got, first) + } +} + func TestXAIExecutorCompactUsesCompactEndpoint(t *testing.T) { validEncryptedContent := testValidGrokEncryptedContent() var gotPath string diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index d7f1b2ea4..f7a168480 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -24,6 +24,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/util" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + coresession "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/session" coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" @@ -315,12 +316,26 @@ func requestExecutionMetadata(ctx context.Context) map[string]any { if executionSessionID := executionSessionIDFromContext(ctx); executionSessionID != "" { meta[coreexecutor.ExecutionSessionMetadataKey] = executionSessionID } + if callerScope := requestCallerScope(ginCtx); callerScope != "" { + meta[coreexecutor.CallerScopeMetadataKey] = callerScope + } if disallowFreeAuthFromContext(ctx) { meta[coreexecutor.DisallowFreeAuthMetadataKey] = true } return meta } +func requestCallerScope(ginCtx *gin.Context) string { + if ginCtx == nil { + return "" + } + value, exists := ginCtx.Get("userApiKey") + if !exists || value == nil { + return "" + } + return coresession.CallerScope(fmt.Sprint(value)) +} + func addAuthSelectionModelMetadata(meta map[string]any, model string) { if meta == nil { return diff --git a/sdk/api/handlers/handlers_metadata_test.go b/sdk/api/handlers/handlers_metadata_test.go index 30b3984d5..183002622 100644 --- a/sdk/api/handlers/handlers_metadata_test.go +++ b/sdk/api/handlers/handlers_metadata_test.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + coresession "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/session" "golang.org/x/net/context" ) @@ -23,6 +24,24 @@ func TestRequestExecutionMetadataIncludesExecutionSessionWithoutIdempotencyKey(t } } +func TestRequestExecutionMetadataIncludesHashedCallerScope(t *testing.T) { + gin.SetMode(gin.TestMode) + ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ginCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + ginCtx.Set("userApiKey", "downstream-secret") + ctx := context.WithValue(context.Background(), "gin", ginCtx) + + meta := requestExecutionMetadata(ctx) + got, _ := meta[coreexecutor.CallerScopeMetadataKey].(string) + want := coresession.CallerScope("downstream-secret") + if got != want { + t.Fatalf("CallerScopeMetadataKey = %q, want %q", got, want) + } + if got == "downstream-secret" { + t.Fatal("caller scope contains the raw downstream credential") + } +} + func TestRequestExecutionMetadataTraceCallbackWebsocketDetection(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 89038100b..a2c5e05bd 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -26,6 +26,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + cliproxysession "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/session" coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" @@ -2577,6 +2578,7 @@ func (m *Manager) Load(ctx context.Context) error { // Execute performs a non-streaming execution using the configured selector and executor. // It supports multiple providers for the same model and round-robins the starting provider per model. func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + req, opts = cliproxysession.Enrich(req, opts) normalized := m.normalizeProviders(providers) if len(normalized) == 0 { return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"} @@ -2618,6 +2620,7 @@ func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxye // It supports multiple providers for the same model and round-robins the starting provider per model. func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + req, opts = cliproxysession.Enrich(req, opts) normalized := m.normalizeProviders(providers) if len(normalized) == 0 { return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"} @@ -2653,6 +2656,7 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip // ExecuteStream performs a streaming execution using the configured selector and executor. // It supports multiple providers for the same model and round-robins the starting provider per model. func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + req, opts = cliproxysession.Enrich(req, opts) if m.HomeEnabled() { if unlockSession := m.lockHomeWebsocketSession(ctx, opts); unlockSession != nil { defer unlockSession() diff --git a/sdk/cliproxy/auth/selector.go b/sdk/cliproxy/auth/selector.go index b76108653..4230bddc0 100644 --- a/sdk/cliproxy/auth/selector.go +++ b/sdk/cliproxy/auth/selector.go @@ -20,6 +20,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + cliproxysession "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/session" ) // RoundRobinSelector provides a simple provider scoped round-robin selection strategy. @@ -403,13 +404,13 @@ func NewSessionAffinitySelectorWithConfig(cfg SessionAffinityConfig) *SessionAff // Pick selects an auth with session affinity when possible. // Priority for session ID extraction: -// 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority -// 2. X-Session-ID header -// 3. Session_id header (Codex) -// 4. X-Client-Request-Id header (PI) -// 5. metadata.user_id (non-Claude Code format) -// 6. conversation_id field in request body -// 7. Stable hash from first few messages content (fallback) +// 1. metadata.user_id containing a Claude Code session +// 2. Explicit session headers +// 3. X-Client-Request-Id header +// 4. Explicit request-body session and user fields +// 5. Explicit execution session metadata +// 6. Stable context-derived session identity +// 7. Legacy message hash fallback // // Note: The cache key includes provider, session ID, and model to handle cases where // a session uses multiple models (e.g., gemini-2.5-pro and gemini-3-flash-preview) @@ -504,13 +505,13 @@ func (s *SessionAffinitySelector) InvalidateAuth(authID string) { // ExtractSessionID extracts session identifier from multiple sources. // Priority order: -// 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority for Claude Code clients -// 2. X-Session-ID header -// 3. Session_id header (Codex) -// 4. X-Client-Request-Id header (PI) -// 5. metadata.user_id (non-Claude Code format) -// 6. conversation_id field in request body -// 7. Stable hash from first few messages content (fallback) +// 1. metadata.user_id containing a Claude Code session +// 2. Explicit session headers +// 3. X-Client-Request-Id header +// 4. Explicit request-body session and user fields +// 5. Explicit execution session metadata +// 6. Stable context-derived session identity +// 7. Legacy message hash fallback func ExtractSessionID(headers http.Header, payload []byte, metadata map[string]any) string { primary, _ := extractSessionIDs(headers, payload, metadata) return primary @@ -540,48 +541,75 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string] } // 2. X-Session-ID header - if headers != nil { - if sid := headers.Get("X-Session-ID"); sid != "" { - return "header:" + sid, "" - } + if sessionID := sessionHeaderValue(headers, "X-Session-ID"); sessionID != "" { + return "header:" + sessionID, "" } // 3. Session_id header (Codex) - if headers != nil { - if sid := headers.Get("Session-Id"); sid != "" { - return "codex:" + sid, "" - } - if sid := headers.Get("Session_id"); sid != "" { - return "codex:" + sid, "" - } + if sessionID := sessionHeaderValue(headers, "Session-Id"); sessionID != "" { + return "codex:" + sessionID, "" + } + if sessionID := sessionHeaderValue(headers, "Session_id"); sessionID != "" { + return "codex:" + sessionID, "" } // 4. X-Client-Request-Id header (PI) - if headers != nil { - if rid := headers.Get("X-Client-Request-Id"); rid != "" { - return "clientreq:" + rid, "" + if requestID := sessionHeaderValue(headers, "X-Client-Request-Id"); requestID != "" { + return "clientreq:" + requestID, "" + } + + if len(payload) > 0 { + // 5. Explicit request-body session fields. + for _, path := range []string{"session_id", "sessionId"} { + if sessionID := strings.TrimSpace(gjson.GetBytes(payload, path).String()); sessionID != "" { + return "session:" + sessionID, "" + } } + if userID := strings.TrimSpace(gjson.GetBytes(payload, "metadata.user_id").String()); userID != "" { + return "user:" + userID, "" + } + if conversationID := strings.TrimSpace(gjson.GetBytes(payload, "conversation_id").String()); conversationID != "" { + return "conv:" + conversationID, "" + } + if promptCacheKey := strings.TrimSpace(gjson.GetBytes(payload, "prompt_cache_key").String()); promptCacheKey != "" { + return "prompt:" + promptCacheKey, "" + } + } + + // 6. Explicit long-lived execution session. + if executionID, ok := metadata[cliproxyexecutor.ExecutionSessionMetadataKey].(string); ok { + if executionID = strings.TrimSpace(executionID); executionID != "" { + return "execution:" + executionID, "" + } + } + + // 7. Stable context-derived session identity. + if derivedID := cliproxysession.DerivedID(metadata); derivedID != "" { + return "derived:" + derivedID, "" } if len(payload) == 0 { return "", "" } - // 6. metadata.user_id (non-Claude Code format) - userID := gjson.GetBytes(payload, "metadata.user_id").String() - if userID != "" { - return "user:" + userID, "" - } - - // 7. conversation_id field - if convID := gjson.GetBytes(payload, "conversation_id").String(); convID != "" { - return "conv:" + convID, "" - } - - // 8. Hash-based fallback from message content + // 8. Legacy hash-based fallback from message content. return extractMessageHashIDs(payload) } +func sessionHeaderValue(headers http.Header, name string) string { + for key, values := range headers { + if !strings.EqualFold(key, name) { + continue + } + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + } + return "" +} + func extractMessageHashIDs(payload []byte) (primaryID, fallbackID string) { var systemPrompt, firstUserMsg, firstAssistantMsg string diff --git a/sdk/cliproxy/auth/selector_test.go b/sdk/cliproxy/auth/selector_test.go index 4896422b4..51e87206d 100644 --- a/sdk/cliproxy/auth/selector_test.go +++ b/sdk/cliproxy/auth/selector_test.go @@ -706,6 +706,45 @@ func TestExtractSessionID_IdempotencyKey(t *testing.T) { } } +func TestExtractSessionID_DerivedSessionAndExplicitPriority(t *testing.T) { + t.Parallel() + + metadata := map[string]any{cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:derived-root"} + payload := []byte(`{"messages":[{"role":"user","content":"hello"}]}`) + if got := ExtractSessionID(nil, payload, metadata); got != "derived:ctx:v1:derived-root" { + t.Fatalf("ExtractSessionID() = %q, want derived identity", got) + } + + executionMetadata := map[string]any{ + cliproxyexecutor.ExecutionSessionMetadataKey: "execution-session", + cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:derived-root", + } + if got := ExtractSessionID(nil, payload, executionMetadata); got != "execution:execution-session" { + t.Fatalf("ExtractSessionID() = %q, want explicit execution session", got) + } + + explicitPayload := []byte(`{"session_id":"explicit-session","prompt_cache_key":"explicit-cache","messages":[{"role":"user","content":"hello"}]}`) + if got := ExtractSessionID(nil, explicitPayload, metadata); got != "session:explicit-session" { + t.Fatalf("ExtractSessionID() = %q, want explicit body session", got) + } + + userPayload := []byte(`{"metadata":{"user_id":"explicit-user"},"conversation_id":"explicit-conversation","messages":[{"role":"user","content":"hello"}]}`) + if got := ExtractSessionID(nil, userPayload, metadata); got != "user:explicit-user" { + t.Fatalf("ExtractSessionID() = %q, want explicit metadata.user_id", got) + } + + lowercaseHeaders := http.Header{"x-session-id": []string{" lowercase-session "}} + if got := ExtractSessionID(lowercaseHeaders, payload, metadata); got != "header:lowercase-session" { + t.Fatalf("ExtractSessionID() = %q, want case-insensitive trimmed header session", got) + } + + headers := make(http.Header) + headers.Set("X-Session-ID", "header-session") + if got := ExtractSessionID(headers, explicitPayload, metadata); got != "header:header-session" { + t.Fatalf("ExtractSessionID() = %q, want explicit header session", got) + } +} + func TestExtractSessionID_MessageHashFallback(t *testing.T) { t.Parallel() diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go index 488db9c2d..d300b2df5 100644 --- a/sdk/cliproxy/executor/types.go +++ b/sdk/cliproxy/executor/types.go @@ -44,6 +44,10 @@ const ( SelectedAuthIndexCallbackMetadataKey = "selected_auth_index_callback" // ExecutionSessionMetadataKey identifies a long-lived downstream execution session. ExecutionSessionMetadataKey = "execution_session_id" + // DerivedSessionIDMetadataKey stores a stable session identity inferred from request context. + DerivedSessionIDMetadataKey = "derived_session_id" + // CallerScopeMetadataKey isolates inferred session identities between downstream callers. + CallerScopeMetadataKey = "caller_scope" ) // Request encapsulates the translated payload that will be sent to a provider executor. diff --git a/sdk/cliproxy/session/identity.go b/sdk/cliproxy/session/identity.go new file mode 100644 index 000000000..3d6c9fffe --- /dev/null +++ b/sdk/cliproxy/session/identity.go @@ -0,0 +1,530 @@ +// Package session derives stable conversation identities from protocol request roots. +package session + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + 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 +) + +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"` +} + +// 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 { + payload = req.Payload + } + if executionID := firstMetadataString(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 + } + if hasExplicitSession(opts.Headers, payload) { + req.Metadata = metadataWithoutKey(req.Metadata, cliproxyexecutor.DerivedSessionIDMetadataKey) + opts.Metadata = metadataWithoutKey(opts.Metadata, cliproxyexecutor.DerivedSessionIDMetadataKey) + return req, opts + } + + derivedID := DerivedID(opts.Metadata) + if derivedID == "" { + derivedID = DerivedID(req.Metadata) + } + 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-Session-ID", "Session-Id", "Session_id", "X-Client-Request-Id"} { + if strings.TrimSpace(headerValue(headers, header)) != "" { + return true + } + } + if len(payload) == 0 { + return false + } + root := gjson.ParseBytes(payload) + for _, path := range []string{"metadata.user_id", "session_id", "sessionId", "conversation_id", "prompt_cache_key"} { + if strings.TrimSpace(root.Get(path).String()) != "" { + return true + } + } + return false +} + +func headerValue(headers map[string][]string, name string) string { + for key, values := range headers { + if !strings.EqualFold(key, name) || len(values) == 0 { + continue + } + return values[0] + } + 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 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())) +} diff --git a/sdk/cliproxy/session/identity_test.go b/sdk/cliproxy/session/identity_test.go new file mode 100644 index 000000000..1fbc498cb --- /dev/null +++ b/sdk/cliproxy/session/identity_test.go @@ -0,0 +1,218 @@ +package session + +import ( + "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: "metadata user id", + payload: []byte(`{"metadata":{"user_id":"explicit-user"},"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 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") + } +}