mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:08:03 +08:00
fix(openai): honor Claude Code session for messages sticky routing
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user