fix(openai): honor Claude Code session for messages sticky routing

This commit is contained in:
RayWangX
2026-08-29 08:31:15 +08:00
committed by RayWangX
parent 7b693ae429
commit 5688bcba91
3 changed files with 71 additions and 4 deletions
@@ -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
}
@@ -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()
+12
View File
@@ -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