openai_compat_prompt_cache_key.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. package service
  2. import (
  3. "crypto/sha256"
  4. "encoding/json"
  5. "fmt"
  6. "strings"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  8. )
  9. const compatPromptCacheKeyPrefix = "compat_cc_"
  10. func shouldAutoInjectPromptCacheKeyForCompat(model string) bool {
  11. trimmed := strings.TrimSpace(strings.ToLower(model))
  12. canonical := canonicalizeOpenAIModelAliasSpelling(trimmed)
  13. // GPT-6 is the public alias for Astra. Keep this deliberately scoped to
  14. // Astra so other GPT-6 families do not inherit Messages compatibility state.
  15. if canonical == "gpt-6" || canonical == "gpt-6-astra" {
  16. return true
  17. }
  18. // 仅对 Responses 兼容路径支持的 GPT-5 族开启自动注入,避免 normalizeCodexModel
  19. // 的默认兜底把任意模型(如 gpt-4o、claude-*)误判为 gpt-5.4。
  20. if !strings.Contains(trimmed, "gpt-5") && !strings.Contains(trimmed, "codex") {
  21. return false
  22. }
  23. normalized := strings.TrimSpace(strings.ToLower(normalizeCodexModel(trimmed)))
  24. return strings.HasPrefix(normalized, "gpt-5") || strings.Contains(normalized, "codex")
  25. }
  26. func deriveCompatPromptCacheKey(req *apicompat.ChatCompletionsRequest, mappedModel string) string {
  27. if req == nil {
  28. return ""
  29. }
  30. normalizedModel := normalizeCodexModel(strings.TrimSpace(mappedModel))
  31. if normalizedModel == "" {
  32. normalizedModel = normalizeCodexModel(strings.TrimSpace(req.Model))
  33. }
  34. if normalizedModel == "" {
  35. normalizedModel = strings.TrimSpace(req.Model)
  36. }
  37. seedParts := []string{"model=" + normalizedModel}
  38. if req.ReasoningEffort != "" {
  39. seedParts = append(seedParts, "reasoning_effort="+strings.TrimSpace(req.ReasoningEffort))
  40. }
  41. if len(req.ToolChoice) > 0 {
  42. seedParts = append(seedParts, "tool_choice="+normalizeCompatSeedJSON(req.ToolChoice))
  43. }
  44. if len(req.Tools) > 0 {
  45. if raw, err := json.Marshal(req.Tools); err == nil {
  46. seedParts = append(seedParts, "tools="+normalizeCompatSeedJSON(raw))
  47. }
  48. }
  49. if len(req.Functions) > 0 {
  50. if raw, err := json.Marshal(req.Functions); err == nil {
  51. seedParts = append(seedParts, "functions="+normalizeCompatSeedJSON(raw))
  52. }
  53. }
  54. firstUserCaptured := false
  55. for _, msg := range req.Messages {
  56. switch strings.TrimSpace(msg.Role) {
  57. case "system":
  58. seedParts = append(seedParts, "system="+normalizeCompatSeedJSON(msg.Content))
  59. case "user":
  60. if !firstUserCaptured {
  61. seedParts = append(seedParts, "first_user="+normalizeCompatSeedJSON(msg.Content))
  62. firstUserCaptured = true
  63. }
  64. }
  65. }
  66. return compatPromptCacheKeyPrefix + hashSensitiveValueForLog(strings.Join(seedParts, "|"))
  67. }
  68. func deriveAnthropicCompatPromptCacheKey(req *apicompat.AnthropicRequest, mappedModel string) string {
  69. if req == nil {
  70. return ""
  71. }
  72. if anchorKey := deriveAnthropicCacheControlPromptCacheKey(req); anchorKey != "" {
  73. return anchorKey
  74. }
  75. normalizedModel := normalizeCodexModel(strings.TrimSpace(mappedModel))
  76. if normalizedModel == "" {
  77. normalizedModel = normalizeCodexModel(strings.TrimSpace(req.Model))
  78. }
  79. if normalizedModel == "" {
  80. normalizedModel = strings.TrimSpace(req.Model)
  81. }
  82. seedParts := []string{"model=" + normalizedModel}
  83. if req.OutputConfig != nil && strings.TrimSpace(req.OutputConfig.Effort) != "" {
  84. seedParts = append(seedParts, "effort="+strings.TrimSpace(req.OutputConfig.Effort))
  85. }
  86. if len(req.ToolChoice) > 0 {
  87. seedParts = append(seedParts, "tool_choice="+normalizeCompatSeedJSON(req.ToolChoice))
  88. }
  89. if len(req.Tools) > 0 {
  90. if raw, err := json.Marshal(req.Tools); err == nil {
  91. seedParts = append(seedParts, "tools="+normalizeCompatSeedJSON(raw))
  92. }
  93. }
  94. if len(req.System) > 0 {
  95. seedParts = append(seedParts, "system="+normalizeCompatSeedJSON(req.System))
  96. }
  97. firstUserCaptured := false
  98. for _, msg := range req.Messages {
  99. if strings.TrimSpace(msg.Role) != "user" || firstUserCaptured {
  100. continue
  101. }
  102. seedParts = append(seedParts, "first_user="+normalizeCompatSeedJSON(msg.Content))
  103. firstUserCaptured = true
  104. }
  105. return compatPromptCacheKeyPrefix + hashSensitiveValueForLog(strings.Join(seedParts, "|"))
  106. }
  107. func deriveAnthropicCacheControlPromptCacheKey(req *apicompat.AnthropicRequest) string {
  108. if req == nil {
  109. return ""
  110. }
  111. var parts []string
  112. var systemBlocks []apicompat.AnthropicContentBlock
  113. if len(req.System) > 0 && json.Unmarshal(req.System, &systemBlocks) == nil {
  114. for _, block := range systemBlocks {
  115. if block.Type == "text" &&
  116. block.CacheControl != nil &&
  117. strings.TrimSpace(block.CacheControl.Type) == "ephemeral" &&
  118. strings.TrimSpace(block.Text) != "" {
  119. parts = append(parts, "system:"+strings.TrimSpace(block.Text))
  120. }
  121. }
  122. }
  123. firstUserAnchor := ""
  124. for _, msg := range req.Messages {
  125. var blocks []apicompat.AnthropicContentBlock
  126. if len(msg.Content) == 0 || json.Unmarshal(msg.Content, &blocks) != nil {
  127. continue
  128. }
  129. role := strings.TrimSpace(msg.Role)
  130. for _, block := range blocks {
  131. if block.Type != "text" ||
  132. block.CacheControl == nil ||
  133. strings.TrimSpace(block.CacheControl.Type) != "ephemeral" ||
  134. strings.TrimSpace(block.Text) == "" {
  135. continue
  136. }
  137. switch role {
  138. case "user":
  139. if firstUserAnchor == "" {
  140. firstUserAnchor = strings.TrimSpace(block.Text)
  141. }
  142. case "assistant":
  143. parts = append(parts, "assistant:"+strings.TrimSpace(block.Text))
  144. }
  145. }
  146. }
  147. if firstUserAnchor != "" {
  148. parts = append(parts, "user_anchor:"+firstUserAnchor)
  149. }
  150. if len(parts) == 0 {
  151. return ""
  152. }
  153. sum := sha256.Sum256([]byte("anthropic-cache:" + strings.Join(parts, "\n")))
  154. return fmt.Sprintf("anthropic-cache-%x", sum[:16])
  155. }
  156. func normalizeCompatSeedJSON(v json.RawMessage) string {
  157. if len(v) == 0 {
  158. return ""
  159. }
  160. var tmp any
  161. if err := json.Unmarshal(v, &tmp); err != nil {
  162. return string(v)
  163. }
  164. out, err := json.Marshal(tmp)
  165. if err != nil {
  166. return string(v)
  167. }
  168. return string(out)
  169. }