openai_codex_account_identity.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. package service
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/json"
  6. "fmt"
  7. "net/http"
  8. "strings"
  9. "github.com/gin-gonic/gin"
  10. "github.com/tidwall/gjson"
  11. "github.com/tidwall/sjson"
  12. )
  13. const codexAccountIdentityNamespaceVersion = "v1"
  14. const codexAccountIdentitySourceContextKey = "openai_codex_account_identity_source"
  15. // prepareCodexAccountIdentitySource resolves credential shadows once per selected
  16. // attempt. The handler reuses gin.Context across failover attempts, so every entry
  17. // point overwrites the staged source before projecting outbound identity.
  18. func (s *OpenAIGatewayService) prepareCodexAccountIdentitySource(ctx context.Context, c *gin.Context, account *Account) (*Account, error) {
  19. source := account
  20. if account != nil && account.IsShadow() {
  21. resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account)
  22. if err != nil {
  23. return nil, err
  24. }
  25. source = resolved
  26. }
  27. if c != nil {
  28. c.Set(codexAccountIdentitySourceContextKey, source)
  29. }
  30. return source, nil
  31. }
  32. func codexAccountIdentitySource(c *gin.Context, fallback *Account) *Account {
  33. if c != nil {
  34. if staged, ok := c.Get(codexAccountIdentitySourceContextKey); ok {
  35. if source, ok := staged.(*Account); ok && source != nil {
  36. return source
  37. }
  38. }
  39. }
  40. return fallback
  41. }
  42. // codexAccountIdentityNamespace returns a stable, credential-scoped namespace.
  43. // Multiple local rows that use the same ChatGPT account intentionally share the
  44. // same namespace. Setup tokens use an irreversible bearer fingerprint because
  45. // they have no refresh lifecycle or imported account metadata. Refreshable OAuth
  46. // otherwise falls back only to a persistent fingerprint seed: local row IDs are
  47. // deployment-relative and must never become upstream identity.
  48. func codexAccountIdentityNamespace(account *Account) string {
  49. if account == nil || !account.IsOpenAIOAuthLike() {
  50. return ""
  51. }
  52. if upstreamAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); upstreamAccountID != "" {
  53. if upstreamUserID := strings.TrimSpace(account.GetCredential("chatgpt_user_id")); upstreamUserID != "" {
  54. return "chatgpt:" + upstreamAccountID + ":user:" + upstreamUserID
  55. }
  56. return "chatgpt:" + upstreamAccountID
  57. }
  58. if seed, ok := codexFingerprintSeed(account.Extra); ok {
  59. return "seed:" + seed
  60. }
  61. if account.Type == AccountTypeSetupToken {
  62. if token := strings.TrimSpace(account.GetOpenAIAccessToken()); token != "" {
  63. sum := sha256.Sum256([]byte("openai-setup-token:" + token))
  64. return fmt.Sprintf("setup-token:%x", sum[:16])
  65. }
  66. }
  67. return ""
  68. }
  69. // isolateOpenAIUpstreamSessionID preserves the existing API-key isolation while
  70. // adding the selected OAuth credential namespace. A scheduler failover therefore
  71. // cannot send the same session/conversation identity through two upstream accounts.
  72. func isolateOpenAIUpstreamSessionID(apiKeyID int64, account *Account, raw string) string {
  73. raw = strings.TrimSpace(raw)
  74. if raw == "" {
  75. return ""
  76. }
  77. namespace := codexAccountIdentityNamespace(account)
  78. if namespace == "" {
  79. return isolateOpenAISessionID(apiKeyID, raw)
  80. }
  81. sum := sha256.Sum256([]byte(fmt.Sprintf("u%d:a%s:%s", apiKeyID, namespace, raw)))
  82. return fmt.Sprintf("%x", sum[:8])
  83. }
  84. func scopeCodexAccountIdentityValue(account *Account, apiKeyID int64, kind, raw string) string {
  85. raw = strings.TrimSpace(raw)
  86. namespace := codexAccountIdentityNamespace(account)
  87. if raw == "" || namespace == "" {
  88. return raw
  89. }
  90. return deriveStableUUIDv4(fmt.Sprintf(
  91. "sub2api:codex-account-identity:%s:user:%d:account:%s:kind:%s:value:%s",
  92. codexAccountIdentityNamespaceVersion,
  93. apiKeyID,
  94. namespace,
  95. kind,
  96. raw,
  97. ))
  98. }
  99. var codexAccountIdentityFields = []struct {
  100. name string
  101. kind string
  102. }{
  103. {name: "installation_id", kind: "installation"},
  104. {name: "x-codex-installation-id", kind: "installation"},
  105. {name: "session_id", kind: "session"},
  106. {name: "session-id", kind: "session"},
  107. {name: "thread_id", kind: "thread"},
  108. {name: "thread-id", kind: "thread"},
  109. {name: "turn_id", kind: "turn"},
  110. {name: "turn-id", kind: "turn"},
  111. {name: "window_id", kind: "window"},
  112. {name: "x-codex-window-id", kind: "window"},
  113. {name: "x-client-request-id", kind: "request"},
  114. }
  115. func applyCodexAccountIdentityFields(values map[string]any, account *Account, apiKeyID int64) bool {
  116. if values == nil || codexAccountIdentityNamespace(account) == "" {
  117. return false
  118. }
  119. changed := false
  120. for _, field := range codexAccountIdentityFields {
  121. raw, ok := values[field.name].(string)
  122. if !ok || strings.TrimSpace(raw) == "" {
  123. continue
  124. }
  125. next := scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw)
  126. if next != raw {
  127. values[field.name] = next
  128. changed = true
  129. }
  130. }
  131. return changed
  132. }
  133. func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *Account, apiKeyID int64) bool {
  134. raw, ok := values[openAIWSTurnMetadataHeader].(string)
  135. if !ok || strings.TrimSpace(raw) == "" {
  136. return false
  137. }
  138. metadata := map[string]any{}
  139. if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil {
  140. return false
  141. }
  142. if !applyCodexAccountIdentityFields(metadata, account, apiKeyID) {
  143. return false
  144. }
  145. rebuilt, err := json.Marshal(metadata)
  146. if err != nil {
  147. return false
  148. }
  149. values[openAIWSTurnMetadataHeader] = string(rebuilt)
  150. return true
  151. }
  152. func applyCodexAccountIdentityClientMetadataMap(requestBody map[string]any, account *Account, apiKeyID int64) bool {
  153. if requestBody == nil || codexAccountIdentityNamespace(account) == "" {
  154. return false
  155. }
  156. changed := false
  157. clientMetadata, _ := requestBody["client_metadata"].(map[string]any)
  158. originalBodySessionID := ""
  159. if clientMetadata != nil {
  160. originalBodySessionID, _ = clientMetadata["session_id"].(string)
  161. if applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) {
  162. changed = true
  163. }
  164. if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) {
  165. changed = true
  166. }
  167. }
  168. if raw, ok := requestBody["prompt_cache_key"].(string); ok && strings.TrimSpace(raw) != "" {
  169. kind := "prompt-cache"
  170. if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID {
  171. kind = "session"
  172. }
  173. next := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw)
  174. if next != raw {
  175. requestBody["prompt_cache_key"] = next
  176. changed = true
  177. }
  178. }
  179. return changed
  180. }
  181. // applyCodexAccountIdentityClientMetadataRaw scopes only the small identity
  182. // subobjects with gjson/sjson. The passthrough hot path never unmarshals the
  183. // potentially multi-megabyte request body.
  184. func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, apiKeyID int64) ([]byte, bool, error) {
  185. if len(body) == 0 || codexAccountIdentityNamespace(account) == "" {
  186. return body, false, nil
  187. }
  188. root := gjson.ParseBytes(body)
  189. if !root.IsObject() {
  190. return body, false, nil
  191. }
  192. next := body
  193. changed := false
  194. originalBodySessionID := ""
  195. if cm := gjson.GetBytes(body, "client_metadata"); cm.IsObject() {
  196. clientMetadata := map[string]any{}
  197. if err := json.Unmarshal([]byte(cm.Raw), &clientMetadata); err != nil {
  198. return body, false, fmt.Errorf("decode client_metadata for account identity: %w", err)
  199. }
  200. originalBodySessionID, _ = clientMetadata["session_id"].(string)
  201. metadataChanged := applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID)
  202. if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) {
  203. metadataChanged = true
  204. }
  205. if metadataChanged {
  206. raw, err := json.Marshal(clientMetadata)
  207. if err != nil {
  208. return body, false, fmt.Errorf("encode account-scoped client_metadata: %w", err)
  209. }
  210. var setErr error
  211. next, setErr = sjson.SetRawBytes(next, "client_metadata", raw)
  212. if setErr != nil {
  213. return body, false, fmt.Errorf("splice account-scoped client_metadata: %w", setErr)
  214. }
  215. changed = true
  216. }
  217. }
  218. if promptCacheKey := gjson.GetBytes(body, "prompt_cache_key"); promptCacheKey.Type == gjson.String && strings.TrimSpace(promptCacheKey.String()) != "" {
  219. raw := promptCacheKey.String()
  220. kind := "prompt-cache"
  221. if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID {
  222. kind = "session"
  223. }
  224. scoped := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw)
  225. if scoped != raw {
  226. rewritten, err := sjson.SetBytes(next, "prompt_cache_key", scoped)
  227. if err != nil {
  228. return body, false, fmt.Errorf("splice account-scoped prompt_cache_key: %w", err)
  229. }
  230. next = rewritten
  231. changed = true
  232. }
  233. }
  234. return next, changed, nil
  235. }
  236. func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, apiKeyID int64) {
  237. if headers == nil || codexAccountIdentityNamespace(account) == "" {
  238. return
  239. }
  240. for _, field := range codexAccountIdentityFields {
  241. // Underscore session/conversation headers are rebuilt separately from the
  242. // prompt cache key by each request builder.
  243. if field.name == "session_id" {
  244. continue
  245. }
  246. raw := strings.TrimSpace(headers.Get(field.name))
  247. if raw != "" {
  248. headers.Set(field.name, scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw))
  249. }
  250. }
  251. if raw := strings.TrimSpace(headers.Get(openAIWSTurnMetadataHeader)); raw != "" {
  252. metadata := map[string]any{}
  253. if err := json.Unmarshal([]byte(raw), &metadata); err == nil && metadata != nil && applyCodexAccountIdentityFields(metadata, account, apiKeyID) {
  254. if rebuilt, err := json.Marshal(metadata); err == nil {
  255. headers.Set(openAIWSTurnMetadataHeader, string(rebuilt))
  256. }
  257. }
  258. }
  259. }