| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275 |
- package service
- import (
- "context"
- "crypto/sha256"
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
- "github.com/gin-gonic/gin"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
- )
- const codexAccountIdentityNamespaceVersion = "v1"
- const codexAccountIdentitySourceContextKey = "openai_codex_account_identity_source"
- // prepareCodexAccountIdentitySource resolves credential shadows once per selected
- // attempt. The handler reuses gin.Context across failover attempts, so every entry
- // point overwrites the staged source before projecting outbound identity.
- func (s *OpenAIGatewayService) prepareCodexAccountIdentitySource(ctx context.Context, c *gin.Context, account *Account) (*Account, error) {
- source := account
- if account != nil && account.IsShadow() {
- resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account)
- if err != nil {
- return nil, err
- }
- source = resolved
- }
- if c != nil {
- c.Set(codexAccountIdentitySourceContextKey, source)
- }
- return source, nil
- }
- func codexAccountIdentitySource(c *gin.Context, fallback *Account) *Account {
- if c != nil {
- if staged, ok := c.Get(codexAccountIdentitySourceContextKey); ok {
- if source, ok := staged.(*Account); ok && source != nil {
- return source
- }
- }
- }
- return fallback
- }
- // codexAccountIdentityNamespace returns a stable, credential-scoped namespace.
- // Multiple local rows that use the same ChatGPT account intentionally share the
- // same namespace. Setup tokens use an irreversible bearer fingerprint because
- // they have no refresh lifecycle or imported account metadata. Refreshable OAuth
- // otherwise falls back only to a persistent fingerprint seed: local row IDs are
- // deployment-relative and must never become upstream identity.
- func codexAccountIdentityNamespace(account *Account) string {
- if account == nil || !account.IsOpenAIOAuthLike() {
- return ""
- }
- if upstreamAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); upstreamAccountID != "" {
- if upstreamUserID := strings.TrimSpace(account.GetCredential("chatgpt_user_id")); upstreamUserID != "" {
- return "chatgpt:" + upstreamAccountID + ":user:" + upstreamUserID
- }
- return "chatgpt:" + upstreamAccountID
- }
- if seed, ok := codexFingerprintSeed(account.Extra); ok {
- return "seed:" + seed
- }
- if account.Type == AccountTypeSetupToken {
- if token := strings.TrimSpace(account.GetOpenAIAccessToken()); token != "" {
- sum := sha256.Sum256([]byte("openai-setup-token:" + token))
- return fmt.Sprintf("setup-token:%x", sum[:16])
- }
- }
- return ""
- }
- // isolateOpenAIUpstreamSessionID preserves the existing API-key isolation while
- // adding the selected OAuth credential namespace. A scheduler failover therefore
- // cannot send the same session/conversation identity through two upstream accounts.
- func isolateOpenAIUpstreamSessionID(apiKeyID int64, account *Account, raw string) string {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return ""
- }
- namespace := codexAccountIdentityNamespace(account)
- if namespace == "" {
- return isolateOpenAISessionID(apiKeyID, raw)
- }
- sum := sha256.Sum256([]byte(fmt.Sprintf("u%d:a%s:%s", apiKeyID, namespace, raw)))
- return fmt.Sprintf("%x", sum[:8])
- }
- func scopeCodexAccountIdentityValue(account *Account, apiKeyID int64, kind, raw string) string {
- raw = strings.TrimSpace(raw)
- namespace := codexAccountIdentityNamespace(account)
- if raw == "" || namespace == "" {
- return raw
- }
- return deriveStableUUIDv4(fmt.Sprintf(
- "sub2api:codex-account-identity:%s:user:%d:account:%s:kind:%s:value:%s",
- codexAccountIdentityNamespaceVersion,
- apiKeyID,
- namespace,
- kind,
- raw,
- ))
- }
- var codexAccountIdentityFields = []struct {
- name string
- kind string
- }{
- {name: "installation_id", kind: "installation"},
- {name: "x-codex-installation-id", kind: "installation"},
- {name: "session_id", kind: "session"},
- {name: "session-id", kind: "session"},
- {name: "thread_id", kind: "thread"},
- {name: "thread-id", kind: "thread"},
- {name: "turn_id", kind: "turn"},
- {name: "turn-id", kind: "turn"},
- {name: "window_id", kind: "window"},
- {name: "x-codex-window-id", kind: "window"},
- {name: "x-client-request-id", kind: "request"},
- }
- func applyCodexAccountIdentityFields(values map[string]any, account *Account, apiKeyID int64) bool {
- if values == nil || codexAccountIdentityNamespace(account) == "" {
- return false
- }
- changed := false
- for _, field := range codexAccountIdentityFields {
- raw, ok := values[field.name].(string)
- if !ok || strings.TrimSpace(raw) == "" {
- continue
- }
- next := scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw)
- if next != raw {
- values[field.name] = next
- changed = true
- }
- }
- return changed
- }
- func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *Account, apiKeyID int64) bool {
- raw, ok := values[openAIWSTurnMetadataHeader].(string)
- if !ok || strings.TrimSpace(raw) == "" {
- return false
- }
- metadata := map[string]any{}
- if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil {
- return false
- }
- if !applyCodexAccountIdentityFields(metadata, account, apiKeyID) {
- return false
- }
- rebuilt, err := json.Marshal(metadata)
- if err != nil {
- return false
- }
- values[openAIWSTurnMetadataHeader] = string(rebuilt)
- return true
- }
- func applyCodexAccountIdentityClientMetadataMap(requestBody map[string]any, account *Account, apiKeyID int64) bool {
- if requestBody == nil || codexAccountIdentityNamespace(account) == "" {
- return false
- }
- changed := false
- clientMetadata, _ := requestBody["client_metadata"].(map[string]any)
- originalBodySessionID := ""
- if clientMetadata != nil {
- originalBodySessionID, _ = clientMetadata["session_id"].(string)
- if applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) {
- changed = true
- }
- if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) {
- changed = true
- }
- }
- if raw, ok := requestBody["prompt_cache_key"].(string); ok && strings.TrimSpace(raw) != "" {
- kind := "prompt-cache"
- if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID {
- kind = "session"
- }
- next := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw)
- if next != raw {
- requestBody["prompt_cache_key"] = next
- changed = true
- }
- }
- return changed
- }
- // applyCodexAccountIdentityClientMetadataRaw scopes only the small identity
- // subobjects with gjson/sjson. The passthrough hot path never unmarshals the
- // potentially multi-megabyte request body.
- func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, apiKeyID int64) ([]byte, bool, error) {
- if len(body) == 0 || codexAccountIdentityNamespace(account) == "" {
- return body, false, nil
- }
- root := gjson.ParseBytes(body)
- if !root.IsObject() {
- return body, false, nil
- }
- next := body
- changed := false
- originalBodySessionID := ""
- if cm := gjson.GetBytes(body, "client_metadata"); cm.IsObject() {
- clientMetadata := map[string]any{}
- if err := json.Unmarshal([]byte(cm.Raw), &clientMetadata); err != nil {
- return body, false, fmt.Errorf("decode client_metadata for account identity: %w", err)
- }
- originalBodySessionID, _ = clientMetadata["session_id"].(string)
- metadataChanged := applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID)
- if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) {
- metadataChanged = true
- }
- if metadataChanged {
- raw, err := json.Marshal(clientMetadata)
- if err != nil {
- return body, false, fmt.Errorf("encode account-scoped client_metadata: %w", err)
- }
- var setErr error
- next, setErr = sjson.SetRawBytes(next, "client_metadata", raw)
- if setErr != nil {
- return body, false, fmt.Errorf("splice account-scoped client_metadata: %w", setErr)
- }
- changed = true
- }
- }
- if promptCacheKey := gjson.GetBytes(body, "prompt_cache_key"); promptCacheKey.Type == gjson.String && strings.TrimSpace(promptCacheKey.String()) != "" {
- raw := promptCacheKey.String()
- kind := "prompt-cache"
- if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID {
- kind = "session"
- }
- scoped := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw)
- if scoped != raw {
- rewritten, err := sjson.SetBytes(next, "prompt_cache_key", scoped)
- if err != nil {
- return body, false, fmt.Errorf("splice account-scoped prompt_cache_key: %w", err)
- }
- next = rewritten
- changed = true
- }
- }
- return next, changed, nil
- }
- func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, apiKeyID int64) {
- if headers == nil || codexAccountIdentityNamespace(account) == "" {
- return
- }
- for _, field := range codexAccountIdentityFields {
- // Underscore session/conversation headers are rebuilt separately from the
- // prompt cache key by each request builder.
- if field.name == "session_id" {
- continue
- }
- raw := strings.TrimSpace(headers.Get(field.name))
- if raw != "" {
- headers.Set(field.name, scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw))
- }
- }
- if raw := strings.TrimSpace(headers.Get(openAIWSTurnMetadataHeader)); raw != "" {
- metadata := map[string]any{}
- if err := json.Unmarshal([]byte(raw), &metadata); err == nil && metadata != nil && applyCodexAccountIdentityFields(metadata, account, apiKeyID) {
- if rebuilt, err := json.Marshal(metadata); err == nil {
- headers.Set(openAIWSTurnMetadataHeader, string(rebuilt))
- }
- }
- }
- }
|