Merge pull request #6079 from okbexx/fix/codex-analytics-account-affinity

fix(openai): scope Codex identity to OAuth account
This commit is contained in:
Wesley Liddick
2026-08-24 14:54:05 +08:00
committed by GitHub
18 changed files with 658 additions and 51 deletions
@@ -133,8 +133,8 @@ func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *test
require.Equal(t, "account-agent-passthrough", req.Header.Get("chatgpt-account-id"))
require.NotEqual(t, "client-session", req.Header.Get("session_id"))
require.NotEqual(t, "client-conversation", req.Header.Get("conversation_id"))
require.Equal(t, isolateOpenAISessionID(0, "client-session"), req.Header.Get("session_id"))
require.Equal(t, isolateOpenAISessionID(0, "client-conversation"), req.Header.Get("conversation_id"))
require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-session"), req.Header.Get("session_id"))
require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-conversation"), req.Header.Get("conversation_id"))
requestBody, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Contains(t, string(requestBody), `"prompt_cache_key":"cache-agent"`)
@@ -147,7 +147,7 @@ func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *test
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"chatgpt_account_id": "account-oauth-passthrough",
"chatgpt_account_id": "account-agent-passthrough",
},
}
oauthRecorder := httptest.NewRecorder()
@@ -30,6 +30,9 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
if s == nil || c == nil || account == nil {
return nil, fmt.Errorf("service, context, and account are required")
}
if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
return nil, err
}
modelResult := gjson.GetBytes(body, "model")
requestedModel := strings.TrimSpace(modelResult.String())
if modelResult.Type != gjson.String || requestedModel == "" {
@@ -272,10 +275,11 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c
}
apiKeyID := getAPIKeyIDFromContext(c)
if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" {
isolated := isolateOpenAISessionID(apiKeyID, sessionID)
isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionID)
req.Header.Set("Session_ID", isolated)
req.Header.Set("Conversation_ID", isolated)
}
applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID)
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
account.ApplyHeaderOverrides(req.Header)
return req, nil
@@ -393,6 +397,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context
if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" {
req.Header.Set("X-Codex-Turn-Metadata", turnMetadata)
}
applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
canonical := resolveCodexOutboundIdentity("")
if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" {
req.Header.Set("Version", version)
@@ -59,6 +59,7 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) {
c.Request.Header.Set("User-Agent", codexCLIUserAgent)
c.Request.Header.Set("Originator", "codex_cli_rs")
c.Request.Header.Set("Version", "0.144.1")
c.Request.Header.Set("X-Codex-Turn-Metadata", `{"session_id":"search-session","turn_id":"search-turn"}`)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
@@ -92,6 +93,14 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) {
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version"))
require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
require.Equal(t,
scopeCodexAccountIdentityValue(account, 0, "session", "search-session"),
gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "session_id").String(),
)
require.Equal(t,
scopeCodexAccountIdentityValue(account, 0, "turn", "search-turn"),
gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(),
)
require.JSONEq(t, string(body), string(upstream.lastBody))
}
@@ -156,7 +165,10 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) {
require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version"))
require.Equal(t, `{"turn_id":"turn-1"}`, upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"))
require.Equal(t,
scopeCodexAccountIdentityValue(account, 0, "turn", "turn-1"),
gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(),
)
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("Originator"))
require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Beta-Features"))
require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Turn-State"))
@@ -0,0 +1,275 @@
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))
}
}
}
}
@@ -0,0 +1,230 @@
package service
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
type codexAccountIdentityRepoStub struct {
AccountRepository
account *Account
}
func (s *codexAccountIdentityRepoStub) GetByID(_ context.Context, _ int64) (*Account, error) {
return s.account, nil
}
func TestCodexRequestBodyIdentityNamespaceIsStablePerOAuthAccount(t *testing.T) {
body := []byte(`{"model":"gpt-5.6-codex","prompt_cache_key":"client-session","client_metadata":{"x-codex-installation-id":"client-installation","session_id":"client-session","thread_id":"client-thread","x-codex-window-id":"client-window","x-codex-turn-metadata":"{\"installation_id\":\"client-installation\",\"session_id\":\"client-session\",\"thread_id\":\"client-thread\",\"turn_id\":\"client-turn\",\"window_id\":\"client-window\"}"}}`)
account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}}
account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}}
first, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77)
require.NoError(t, err)
require.True(t, changed)
firstAgain, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77)
require.NoError(t, err)
require.True(t, changed)
second, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account19, 77)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, string(first), string(firstAgain))
paths := []string{
"prompt_cache_key",
"client_metadata.x-codex-installation-id",
"client_metadata.session_id",
"client_metadata.thread_id",
"client_metadata.x-codex-window-id",
}
for _, path := range paths {
require.NotEqual(t, gjson.GetBytes(body, path).String(), gjson.GetBytes(first, path).String(), path)
require.NotEqual(t, gjson.GetBytes(first, path).String(), gjson.GetBytes(second, path).String(), path)
}
require.Equal(t, gjson.GetBytes(first, "prompt_cache_key").String(), gjson.GetBytes(first, "client_metadata.session_id").String())
var embeddedFirst map[string]any
var embeddedSecond map[string]any
require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(first, "client_metadata.x-codex-turn-metadata").String()), &embeddedFirst))
require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(second, "client_metadata.x-codex-turn-metadata").String()), &embeddedSecond))
for _, field := range []string{"installation_id", "session_id", "thread_id", "turn_id", "window_id"} {
require.NotEqual(t, embeddedFirst[field], embeddedSecond[field], field)
}
}
func TestCodexAccountIdentityNamespaceUsesStableCredentialSource(t *testing.T) {
firstRow := &Account{ID: 8, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "shared-upstream-account"}}
secondRow := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "shared-upstream-account"}}
require.Equal(t, codexAccountIdentityNamespace(firstRow), codexAccountIdentityNamespace(secondRow))
firstUser := &Account{ID: 20, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-1"}}
sameUser := &Account{ID: 21, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-1"}}
secondUser := &Account{ID: 22, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-2"}}
require.Equal(t, codexAccountIdentityNamespace(firstUser), codexAccountIdentityNamespace(sameUser))
require.NotEqual(t, codexAccountIdentityNamespace(firstUser), codexAccountIdentityNamespace(secondUser))
seed := "11111111-1111-4111-8111-111111111111"
seeded := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintSeedExtraKey: seed}}
require.Equal(t, "seed:"+seed, codexAccountIdentityNamespace(seeded))
// Local row IDs repeat across independent deployments, so they are not a
// safe fallback for upstream identity.
require.Empty(t, codexAccountIdentityNamespace(&Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth}))
setupTokenA := &Account{ID: 30, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-a"}}
setupTokenADuplicate := &Account{ID: 31, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-a"}}
setupTokenB := &Account{ID: 32, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-b"}}
setupNamespace := codexAccountIdentityNamespace(setupTokenA)
require.NotEmpty(t, setupNamespace)
require.NotContains(t, setupNamespace, "setup-token-a")
require.Equal(t, setupNamespace, codexAccountIdentityNamespace(setupTokenADuplicate))
require.NotEqual(t, setupNamespace, codexAccountIdentityNamespace(setupTokenB))
}
func TestCodexAccountIdentitySourceResolvesShadowAndOverwritesFailoverContext(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
parentID := int64(11)
parent := &Account{ID: parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{
"chatgpt_account_id": "team-account",
"chatgpt_user_id": "user-1",
}}
shadow := &Account{ID: 111, ParentAccountID: &parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
service := &OpenAIGatewayService{accountRepo: &codexAccountIdentityRepoStub{account: parent}}
resolved, err := service.prepareCodexAccountIdentitySource(context.Background(), c, shadow)
require.NoError(t, err)
require.Same(t, parent, resolved)
require.Same(t, parent, codexAccountIdentitySource(c, shadow))
req, err := service.buildUpstreamRequest(
context.Background(), c, shadow,
[]byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`),
"token", true, "client-session", true,
)
require.NoError(t, err)
require.Equal(t, isolateOpenAIUpstreamSessionID(0, parent, "client-session"), req.Header.Get("session_id"))
next := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{
"chatgpt_account_id": "other-account",
"chatgpt_user_id": "user-2",
}}
resolved, err = service.prepareCodexAccountIdentitySource(context.Background(), c, next)
require.NoError(t, err)
require.Same(t, next, resolved)
require.Same(t, next, codexAccountIdentitySource(c, shadow))
}
func TestBuildOpenAIWSHeadersNamespacesCodexIdentityByOAuthAccount(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
c.Set("api_key_id", int64(77))
c.Request.Header.Set("x-codex-installation-id", "client-installation")
c.Request.Header.Set("thread-id", "client-thread")
c.Request.Header.Set("x-codex-window-id", "client-window")
c.Request.Header.Set("x-client-request-id", "client-request")
account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}}
account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}}
service := &OpenAIGatewayService{}
build := func(account *Account) http.Header {
headers, _, err := service.buildOpenAIWSHeaders(
context.Background(), c, account, "token",
OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2},
true, "", "", "client-session", "", "",
)
require.NoError(t, err)
return headers
}
first := build(account11)
firstAgain := build(account11)
second := build(account19)
for _, header := range []string{"session_id", "x-codex-installation-id", "thread-id", "x-codex-window-id", "x-client-request-id"} {
require.NotEmpty(t, first.Get(header), header)
require.Equal(t, first.Get(header), firstAgain.Get(header), header)
require.NotEqual(t, first.Get(header), second.Get(header), header)
}
httpRequest, err := service.buildUpstreamRequest(
context.Background(), c, account11,
[]byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`),
"token", true, "client-session", true,
)
require.NoError(t, err)
require.Equal(t, httpRequest.Header.Get("session_id"), first.Get("session_id"), "HTTP and WS must derive the same identity from the raw client key")
}
func TestBuildUpstreamRequestNamespacesCodexIdentityByOAuthAccount(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &OpenAIGatewayService{}
body := []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`)
build := func(accountID int64, chatgptAccountID string) http.Header {
t.Helper()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
c.Set("api_key", &APIKey{ID: 77})
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.0")
c.Request.Header.Set("x-codex-installation-id", "client-installation")
c.Request.Header.Set("x-codex-window-id", "client-window")
c.Request.Header.Set("session-id", "client-session")
c.Request.Header.Set("thread-id", "client-thread")
c.Request.Header.Set("x-client-request-id", "client-request")
c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"client-installation","session_id":"client-session","thread_id":"client-thread","turn_id":"client-turn","window_id":"client-window"}`)
account := &Account{
ID: accountID,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Credentials: map[string]any{
"chatgpt_account_id": chatgptAccountID,
},
}
req, err := svc.buildUpstreamRequest(
context.Background(), c, account, body, "oauth-token", true, "client-session", true,
)
require.NoError(t, err)
return req.Header
}
first := build(11, "chatgpt-account-11")
firstAgain := build(11, "chatgpt-account-11")
second := build(19, "chatgpt-account-19")
identityHeaders := []string{
"x-codex-installation-id",
"x-codex-window-id",
"session-id",
"session_id",
"conversation_id",
"thread-id",
"x-client-request-id",
"x-codex-turn-metadata",
}
checked := 0
for _, header := range identityHeaders {
if first.Get(header) == "" && second.Get(header) == "" {
continue
}
checked++
require.NotEmpty(t, first.Get(header), header)
require.Equal(t, first.Get(header), firstAgain.Get(header), "same account must retain stable identity: %s", header)
require.NotEqual(t, first.Get(header), second.Get(header), "account failover must rotate upstream identity: %s", header)
}
require.GreaterOrEqual(t, checked, 5, "test must exercise the real outbound identity surface")
}
@@ -1010,7 +1010,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, secondResult)
require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator)
require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
@@ -61,6 +61,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
) (*OpenAIForwardResult, error) {
beginUpstreamResponseModelObservation(c)
setCodexToolNameReverse(c, nil)
if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
return nil, err
}
restrictionResult := s.detectCodexClientRestriction(c, account, body)
logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body)
@@ -261,6 +264,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
} else if promptCacheKey != "" {
reqBody["prompt_cache_key"] = promptCacheKey
}
applyCodexAccountIdentityClientMetadataMap(reqBody, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
responsesBody, err = json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("remarshal after codex transform: %w", err)
@@ -314,7 +318,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
if promptCacheKey != "" {
apiKeyID := getAPIKeyIDFromContext(c)
upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey)))
upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey)))
}
// 7. Send request
@@ -24,6 +24,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
clearOpenAIResponsesClientToolMapping(c)
clearOpenAIResponsesNamespaceNames(c)
setCodexToolNameReverse(c, nil)
if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
return nil, err
}
startTime := time.Now()
// 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。
canonicalImageIntentBody := body
@@ -252,6 +255,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
bodyModified := false
clientPromptCacheKey := promptCacheKey
var reqBody map[string]any
ensureReqBody := func() (map[string]any, error) {
if requestView.HasPatches() {
@@ -478,6 +482,15 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if !isCompactRequest && applyCodexClientMetadata(decoded, account) {
markDecodedModified()
}
if currentClientPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok {
clientPromptCacheKey = currentClientPromptCacheKey
}
// Account namespace is orthogonal to fingerprint convergence: preserve
// each client's identity cardinality, but never reuse it across OAuth
// credentials after scheduler failover.
if !isCompactRequest && applyCodexAccountIdentityClientMetadataMap(decoded, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) {
markDecodedModified()
}
stageCodexFingerprintIDs(c, nil)
// 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。
// fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。
@@ -500,7 +513,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if codexResult.NormalizedModel != "" {
upstreamModel = codexResult.NormalizedModel
}
if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" {
if strings.TrimSpace(clientPromptCacheKey) != "" {
// The body now carries an account-scoped value. Keep the original here
// so the header builder derives the same namespace exactly once.
promptCacheKey = clientPromptCacheKey
} else if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" {
// Fingerprint convergence may inject a default key when the client did
// not provide one; preserve that existing fallback.
promptCacheKey = currentPromptCacheKey
} else if codexResult.PromptCacheKey != "" {
promptCacheKey = codexResult.PromptCacheKey
@@ -773,6 +792,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
c,
account,
wsReqBody,
clientPromptCacheKey,
token,
wsDecision,
isCodexCLI,
@@ -1321,12 +1341,12 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
req.Header.Set("version", CodexCanonicalClientVersion())
}
compactSession := resolveOpenAICompactSessionID(c)
req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession))
req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), compactSession))
} else {
req.Header.Set("accept", "text/event-stream")
}
if promptCacheKey != "" {
isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey)
isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey)
req.Header.Set("session_id", isolated)
if !compatMessagesBridge || clientConversationID != "" {
req.Header.Set("conversation_id", isolated)
@@ -1350,6 +1370,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
req.Header.Set("user-agent", CodexCanonicalUserAgent())
}
// 账号 namespace 不改变客户端身份基数,但确保 scheduler failover 后不会把
// 同一组 Codex IDs 发送给另一份 OAuth 凭据。可选指纹收敛随后仍可覆盖这些值。
applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
// 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。
applyStagedCodexFingerprintHeaders(c, account, req.Header)
@@ -35,6 +35,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
) (*OpenAIForwardResult, error) {
beginUpstreamResponseModelObservation(c)
setCodexToolNameReverse(c, nil)
if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
return nil, err
}
// 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时,
// /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留
@@ -227,6 +230,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
if codexResult.PromptCacheKey != "" {
promptCacheKey = codexResult.PromptCacheKey
}
applyCodexAccountIdentityClientMetadataMap(reqBody, codexAccountIdentitySource(c, account), apiKeyID)
delete(reqBody, "prompt_cache_key")
if shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) {
compatTurnState = s.getOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey)
@@ -329,7 +333,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
// Override session_id with a deterministic UUID derived from the isolated
// session key, ensuring different API keys produce different upstream sessions.
if account.Platform != PlatformGrok && promptCacheKey != "" {
isolatedSessionID := generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey))
isolatedSessionID := generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey))
upstreamReq.Header.Set("session_id", isolatedSessionID)
if upstreamReq.Header.Get("conversation_id") != "" {
upstreamReq.Header.Set("conversation_id", isolatedSessionID)
@@ -180,6 +180,14 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}
reqStream = gjson.GetBytes(body, "stream").Bool()
accountScopedBody, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(body, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
if scopeErr != nil {
return nil, scopeErr
}
if accountScoped {
body = accountScopedBody
}
stageCodexFingerprintIDs(c, nil)
// 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。
// 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科
@@ -670,10 +678,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
clientConversationID = promptCacheKey
}
if clientSessionID != "" {
req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, clientSessionID))
req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientSessionID))
}
if clientConversationID != "" {
req.Header.Set("conversation_id", isolateOpenAISessionID(apiKeyID, clientConversationID))
req.Header.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientConversationID))
}
} else if isOpenAIResponsesCompactPath(c) {
// 透传白名单会放行客户端的 Accept: text/event-stream;compact 上游是
@@ -690,6 +698,8 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
req.Header.Set("user-agent", CodexCanonicalUserAgent())
}
applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
// 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头,
// 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置:
// 会话隔离之后、终态身份收口之前)。
@@ -227,7 +227,7 @@ func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) {
require.NotNil(t, secondResult)
require.True(t, isOpenAICompatMessagesBridgeContext(secondCtx))
require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state"))
require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex-tui")
}
@@ -86,6 +86,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
// A handler may reuse the same gin context across account failover attempts.
// Never let an OAuth attempt's response aliases leak into the next account.
setCodexToolNameReverse(c, nil)
if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
return err
}
if err := validateOpenAIWSBearerToken(account, token); err != nil {
return err
}
@@ -181,15 +184,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI)
type openAIWSClientPayload struct {
payloadRaw []byte
rawForHash []byte
promptCacheKey string
previousResponseID string
originalModel string
imageBillingModel string
imageSizeTier string
imageInputSize string
payloadBytes int
payloadRaw []byte
accountIdentitySourceRaw []byte
rawForHash []byte
promptCacheKey string
previousResponseID string
originalModel string
imageBillingModel string
imageSizeTier string
imageInputSize string
payloadBytes int
}
ingressSessionOriginalModel := ""
@@ -308,6 +312,14 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
normalized = next
}
accountIdentitySourceRaw := append([]byte(nil), normalized...)
accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(normalized, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
if scopeErr != nil {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr)
}
if accountScoped {
normalized = accountScopedPayload
}
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(normalized) {
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized)
if liteErr != nil {
@@ -450,15 +462,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
ingressSessionOriginalModel = originalModel
return openAIWSClientPayload{
payloadRaw: normalized,
rawForHash: trimmed,
promptCacheKey: promptCacheKey,
previousResponseID: previousResponseID,
originalModel: originalModel,
imageBillingModel: imageBillingModel,
imageSizeTier: imageSizeTier,
imageInputSize: imageInputSize,
payloadBytes: len(normalized),
payloadRaw: normalized,
accountIdentitySourceRaw: accountIdentitySourceRaw,
rawForHash: trimmed,
promptCacheKey: promptCacheKey,
previousResponseID: previousResponseID,
originalModel: originalModel,
imageBillingModel: imageBillingModel,
imageSizeTier: imageSizeTier,
imageInputSize: imageInputSize,
payloadBytes: len(normalized),
}, nil
}
@@ -645,7 +658,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
var failoverErr *UpstreamFailoverError
if turn > 1 && errors.As(bridgeErr, &failoverErr) && failoverErr != nil {
retryPayload, retrySafe, retryPayloadErr := buildOpenAIWSCurrentTurnRetryPayload(
bridgePayloadRaw,
currentBridgePayload.accountIdentitySourceRaw,
turnAccountFailoverInput,
turnAccountFailoverInputExists,
currentBridgePayload.originalModel,
@@ -1242,7 +1242,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade
t.Fatal("等待 passthrough websocket 结束超时")
}
require.Equal(t, isolateOpenAISessionID(0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id"))
require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id"))
require.Equal(t, "turn-state-1", captureDialer.lastHeaders.Get(openAIWSTurnStateHeader))
require.Equal(t, "turn-meta-1", captureDialer.lastHeaders.Get(openAIWSTurnMetadataHeader))
require.Len(t, upstreamConn.writes, 1)
@@ -121,10 +121,10 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
if account != nil && account.UsesOpenAICodexProtocol() {
apiKeyID := getAPIKeyIDFromContext(c)
if sessionResolution.SessionID != "" {
headers.Set("session_id", isolateOpenAISessionID(apiKeyID, sessionResolution.SessionID))
headers.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.SessionID))
}
if sessionResolution.ConversationID != "" {
headers.Set("conversation_id", isolateOpenAISessionID(apiKeyID, sessionResolution.ConversationID))
headers.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.ConversationID))
}
} else {
if sessionResolution.SessionID != "" {
@@ -140,6 +140,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
if metadata := strings.TrimSpace(turnMetadata); metadata != "" {
headers.Set(openAIWSTurnMetadataHeader, metadata)
}
applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
applyStagedCodexFingerprintHeaders(c, account, headers)
if account != nil && account.UsesOpenAICodexProtocol() {
@@ -430,7 +430,7 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *test
require.Empty(t, headers.Get("X-Test"))
}
func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesClientSessionIdentity(t *testing.T) {
func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesNamespacedClientSessionIdentity(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -465,10 +465,10 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesClientSessi
require.NoError(t, err)
require.Equal(t, ids.installationID, headers.Get("x-codex-installation-id"))
require.NotEqual(t, "client-installation", headers.Get("x-codex-installation-id"))
require.Equal(t, "client-window", headers.Get("x-codex-window-id"))
require.Equal(t, "client-session", headers.Get("session-id"))
require.Equal(t, "client-thread", headers.Get("thread-id"))
require.Equal(t, "client-request", headers.Get("x-client-request-id"))
require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "window", "client-window"), headers.Get("x-codex-window-id"))
require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), headers.Get("session-id"))
require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), headers.Get("thread-id"))
require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "request", "client-request"), headers.Get("x-client-request-id"))
}
func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) {
@@ -798,10 +798,10 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
require.Equal(t, "native-wsv2", gjson.Get(requestJSON, "input.0.namespace").String(), "OAuth WSv2 应保留原生 namespace")
require.Equal(t, openAIWSBetaV2Value, captureDialer.lastHeaders.Get("OpenAI-Beta"))
require.Equal(t, "remote_compaction_v2", captureDialer.lastHeaders.Get("x-codex-beta-features"))
// OAuth 账号的 session_id/conversation_id 应被 isolateOpenAISessionID 隔离,
// OAuth 账号的 session_id/conversation_id 应同时按 API key 和上游账号隔离,
// 测试中未设置 api_key 到 context,apiKeyID=0。
require.Equal(t, isolateOpenAISessionID(0, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id"))
require.Equal(t, isolateOpenAISessionID(0, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id"))
require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id"))
require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id"))
}
func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) {
@@ -1023,8 +1023,8 @@ func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheK
require.NotNil(t, result)
require.Equal(t, "resp_prompt_cache_key", result.RequestID)
// OAuth 账号的 session_id 应被 isolateOpenAISessionID 隔离(apiKeyID=0,未在 context 设置)。
require.Equal(t, isolateOpenAISessionID(0, "pcache_123"), captureDialer.lastHeaders.Get("session_id"))
// OAuth 账号的 session_id 应同时按 API key 和上游账号隔离(apiKeyID=0)。
require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "pcache_123"), captureDialer.lastHeaders.Get("session_id"))
require.Empty(t, captureDialer.lastHeaders.Get("conversation_id"))
require.NotNil(t, captureConn.lastWrite)
require.True(t, gjson.Get(requestToJSONString(captureConn.lastWrite), "stream").Exists())
@@ -22,6 +22,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
c *gin.Context,
account *Account,
reqBody map[string]any,
clientPromptCacheKey string,
token string,
decision OpenAIWSProtocolDecision,
isCodexCLI bool,
@@ -72,7 +73,12 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
applyStagedCodexFingerprintClientMetadata(c, account, payload)
previousResponseID := openAIWSPayloadString(payload, "previous_response_id")
previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID)
promptCacheKey := openAIWSPayloadString(payload, "prompt_cache_key")
promptCacheKey := strings.TrimSpace(clientPromptCacheKey)
if promptCacheKey == "" {
// Fingerprint convergence may inject a default key when the client did
// not send one; retain that fallback without replacing an explicit raw key.
promptCacheKey = openAIWSPayloadString(payload, "prompt_cache_key")
}
_, hasTools := payload["tools"]
debugEnabled := isOpenAIWSModeDebugEnabled()
payloadBytes := -1
@@ -150,11 +150,13 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t
account := &Account{
ID: 129, Name: "limited", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Schedulable: true, Concurrency: 1,
Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge},
Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge},
Credentials: map[string]any{"chatgpt_account_id": "account-a", "chatgpt_user_id": "user-a"},
}
nextAccount := *account
nextAccount.ID = 130
nextAccount.Name = "replacement"
nextAccount.Credentials = map[string]any{"chatgpt_account_id": "account-b", "chatgpt_user_id": "user-b"}
serverErrCh := make(chan error, 1)
failoverCh := make(chan []byte, 1)
@@ -212,7 +214,7 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t
require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String())
writeCtx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_first","input":[{"type":"function_call_output","call_id":"call_1","output":"second"}]}`))
err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_first","prompt_cache_key":"client-session","client_metadata":{"session_id":"client-session","thread_id":"client-thread"},"input":[{"type":"function_call_output","call_id":"call_1","output":"second"}]}`))
cancel()
require.NoError(t, err)
@@ -237,6 +239,8 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t
require.Contains(t, input.Raw, "second")
require.Equal(t, 1, strings.Count(input.Raw, `"id":"fc_1"`))
require.Equal(t, 2, strings.Count(input.Raw, `"call_id":"call_1"`))
require.Equal(t, "client-session", gjson.GetBytes(retryPayload, "client_metadata.session_id").String())
require.Equal(t, "client-thread", gjson.GetBytes(retryPayload, "client_metadata.thread_id").String())
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for current-turn failover")
}
@@ -249,7 +253,11 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t
}
require.Len(t, upstream.bodies, 3)
require.Contains(t, string(upstream.bodies[0]), "first")
require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[1], "client_metadata.session_id").String())
require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[1], "client_metadata.thread_id").String())
require.NotContains(t, string(upstream.bodies[2]), "previous_response_id")
require.Contains(t, string(upstream.bodies[2]), "second")
require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[2], "client_metadata.session_id").String())
require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[2], "client_metadata.thread_id").String())
require.Empty(t, upstream.requests[2].Header.Get(openAIWSTurnStateHeader))
}
@@ -693,6 +693,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
}
requestModel := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String())
requestPreviousResponseID := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "previous_response_id").String())
promptCacheKey := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "prompt_cache_key").String())
logOpenAIWSV2Passthrough(
"relay_start account_id=%d model=%s previous_response_id=%s first_message_type=%s first_message_bytes=%d",
account.ID,
@@ -749,6 +750,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
firstClientMessage = aliasedBody
}
}
accountScopedFirst, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(firstClientMessage, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
if scopeErr != nil {
return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr)
}
if accountScoped {
firstClientMessage = accountScopedFirst
}
usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage)
updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage)
if policyErr != nil {
@@ -789,8 +797,6 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
usageMeta.initFromFirstFrame(firstClientMessage, capturedSessionModel)
_, initialUpstreamModel := usageMeta.turnModels(initialRequestModel)
SetOpsUpstreamModel(c, initialUpstreamModel)
promptCacheKey := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "prompt_cache_key").String())
wsURL, err := s.buildOpenAIResponsesWSURL(account)
if err != nil {
return fmt.Errorf("build ws url: %w", err)
@@ -995,6 +1001,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
payload = aliasedBody
}
}
if isResponseCreate || eventType == "session.update" {
accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(payload, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
if scopeErr != nil {
return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr)
}
if accountScoped {
payload = accountScopedPayload
}
}
if isResponseCreate {
if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(payload) {
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload)