Files
sub2api/backend/internal/service/openai_codex_account_identity.go
T

276 lines
9.2 KiB
Go

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))
}
}
}
}