From 5688bcba91c6457ceece26053ccc40537a46cc47 Mon Sep 17 00:00:00 2001 From: RayWangX <19251440+swjturay@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:05:51 +0800 Subject: [PATCH] fix(openai): honor Claude Code session for messages sticky routing --- .../handler/openai_gateway_handler.go | 13 ++++- .../handler/openai_gateway_handler_test.go | 50 ++++++++++++++++++- backend/internal/service/session_id.go | 12 +++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index fd269a6a2c..bee0b59061 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1183,7 +1183,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { sessionHash := h.gatewayService.GenerateSessionHash(c, body) promptCacheKey := h.gatewayService.ExtractSessionID(c, body) - sessionHash, promptCacheKey = resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel, body) + sessionHash, promptCacheKey = resolveOpenAIMessagesMetadataSession(c, sessionHash, promptCacheKey, reqModel, body) if h.rejectIfCyberSessionBlocked(c, apiKey, body, reqModel, cyberBlockFormatAnthropic) { return } @@ -1461,10 +1461,19 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } } -func resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel string, body []byte) (string, string) { +func resolveOpenAIMessagesMetadataSession(c *gin.Context, sessionHash, promptCacheKey, reqModel string, body []byte) (string, string) { // Anthropic metadata.user_id 只作为账号粘性信号。上游 GPT/Codex 缓存键 // 交给 ForwardAsAnthropic 从 cache_control 或完整消息 digest 派生,避免 // 固定 metadata key 压住后续 turn 的缓存滚动。 + // + // Claude Code 的 X-Claude-Code-Session-Id 是比 body content fallback 更稳定的 + // 会话边界,但它只用于本地账号粘性;不要把它提升为 prompt_cache_key 或上游 + // session_id,否则会改变现有 Messages→Codex 缓存滚动语义。 + if promptCacheKey == "" { + if claudeSessionID := service.ClaudeCodeSessionIDFromHeader(c); claudeSessionID != "" { + return service.DeriveSessionHashFromSeed(claudeSessionID), promptCacheKey + } + } if sessionHash != "" { return sessionHash, promptCacheKey } diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 86c56d4901..23acd158b3 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -176,7 +176,7 @@ func TestOpenAIResponsesRequiredCapability(t *testing.T) { func TestResolveOpenAIMessagesMetadataSession_DoesNotDerivePromptCacheKey(t *testing.T) { body := []byte(`{"model":"claude-sonnet-4-5","metadata":{"user_id":"claude-code-session"},"messages":[{"role":"user","content":"hello"}]}`) - sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession("", "", "claude-sonnet-4-5", body) + sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession(nil, "", "", "claude-sonnet-4-5", body) require.NotEmpty(t, sessionHash) require.Empty(t, promptCacheKey) @@ -185,12 +185,58 @@ func TestResolveOpenAIMessagesMetadataSession_DoesNotDerivePromptCacheKey(t *tes func TestResolveOpenAIMessagesMetadataSession_PreservesExplicitPromptCacheKey(t *testing.T) { body := []byte(`{"metadata":{"user_id":"claude-code-session"}}`) - sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession("", "explicit-cache", "claude-sonnet-4-5", body) + sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession(nil, "", "explicit-cache", "claude-sonnet-4-5", body) require.NotEmpty(t, sessionHash) require.Equal(t, "explicit-cache", promptCacheKey) } +func TestResolveOpenAIMessagesMetadataSession_ClaudeCodeHeaderOverridesContentFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("X-Claude-Code-Session-Id", "claude-session-001") + + body1 := []byte(`{"model":"gpt-5.6-sol","system":"parent","messages":[{"role":"user","content":"parent task"}]}`) + body2 := []byte(`{"model":"gpt-5.6-sol","system":"subagent","messages":[{"role":"user","content":"child task"}]}`) + + contentHash1 := (&service.OpenAIGatewayService{}).GenerateSessionHash(c, body1) + contentHash2 := (&service.OpenAIGatewayService{}).GenerateSessionHash(c, body2) + require.NotEqual(t, contentHash1, contentHash2, "different bodies should prove the content fallback differs") + + hash1, cacheKey1 := resolveOpenAIMessagesMetadataSession(c, contentHash1, "", "gpt-5.6-sol", body1) + hash2, cacheKey2 := resolveOpenAIMessagesMetadataSession(c, contentHash2, "", "gpt-5.6-sol", body2) + require.Equal(t, service.DeriveSessionHashFromSeed("claude-session-001"), hash1) + require.Equal(t, hash1, hash2, "the same Claude Code session must keep one sticky account across changed turn bodies") + require.Empty(t, cacheKey1, "routing-only fix must not create an upstream prompt cache key") + require.Empty(t, cacheKey2, "routing-only fix must not create an upstream prompt cache key") +} + +func TestResolveOpenAIMessagesMetadataSession_OpenAISignalWinsOverClaudeHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("X-Claude-Code-Session-Id", "claude-session-001") + + hash, cacheKey := resolveOpenAIMessagesMetadataSession(c, "content-hash", "explicit-openai-session", "gpt-5.6-sol", []byte(`{"metadata":{"user_id":"opaque"}}`)) + require.Equal(t, "content-hash", hash, "existing OpenAI session resolution must remain authoritative") + require.Equal(t, "explicit-openai-session", cacheKey) +} + +func TestResolveOpenAIMessagesMetadataSession_BlankClaudeHeaderKeepsContentFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("X-Claude-Code-Session-Id", " ") + + hash, cacheKey := resolveOpenAIMessagesMetadataSession(c, "content-hash", "", "gpt-5.6-sol", []byte(`{"metadata":{"user_id":"opaque"}}`)) + require.Equal(t, "content-hash", hash) + require.Empty(t, cacheKey) +} + func TestOpenAIHandleStreamingAwareError_NonStreaming(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/backend/internal/service/session_id.go b/backend/internal/service/session_id.go index 8cf39f056b..9c031eb134 100644 --- a/backend/internal/service/session_id.go +++ b/backend/internal/service/session_id.go @@ -20,6 +20,18 @@ var clientSessionIDHeaders = append( claudeCodeSessionHeader, ) +// ClaudeCodeSessionIDFromHeader returns the stable Claude Code conversation +// identifier carried by X-Claude-Code-Session-Id. It is intentionally exposed +// separately from ExtractClientSessionID: callers that use it for routing must +// make that scope explicit rather than accidentally changing every protocol's +// session semantics. +func ClaudeCodeSessionIDFromHeader(c *gin.Context) string { + if c == nil || c.Request == nil { + return "" + } + return sanitizeSessionID(c.GetHeader(claudeCodeSessionHeader)) +} + // ExtractClientSessionID resolves the explicit client-provided session identifier from // request headers for usage-log correlation and returns it sanitized. It is // protocol-agnostic and shared by every gateway handler so all supported protocols