From c0ab3a00ea733cc0559a5a949c28fb5d9d7c5d16 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 12 Aug 2026 17:57:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Codex=20OAuth=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=8C=87=E7=BA=B9=E6=94=B6=E6=95=9B=EF=BC=8C=E5=87=8F=E5=B0=91?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E5=8F=AF=E8=A7=81=E7=9A=84=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=95=B0=E5=92=8C=E4=BC=9A=E8=AF=9D=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多人共享同一 OAuth 账号时,各用户 Codex 客户端携带各自不同的 installation_id/session_id/thread_id, 上游据此判定设备数和会话数并限制配额。本功能将这些标识改写为账号级恒定值。 四档策略(账号级 extra 字段 codex_fingerprint_mode): - off: 不做任何收敛,原样透传 - device: 仅收敛 installation_id - session(默认): 收敛 installation_id + session_id,thread_id 按客户端原始 session 派生 - full: 收敛所有标识(installation_id + session_id + thread_id) 改写覆盖 6 个指纹载体:x-codex-turn-metadata 头(JSON 内部字段)、x-codex-window-id、 x-codex-installation-id、x-client-request-id、session-id/session_id/thread-id、 请求体 client_metadata。头和体共享同一份预计算 IDs 确保 turn_id 等随机字段一致。 --- .../service/openai_codex_fingerprint.go | 301 ++++++++++++ .../service/openai_codex_fingerprint_test.go | 447 ++++++++++++++++++ .../service/openai_gateway_forward.go | 27 ++ .../account/BulkEditAccountModal.vue | 39 ++ .../components/account/CreateAccountModal.vue | 32 ++ .../components/account/EditAccountModal.vue | 43 ++ .../src/i18n/locales/en/admin/accounts.ts | 6 + .../src/i18n/locales/zh/admin/accounts.ts | 6 + 8 files changed, 901 insertions(+) create mode 100644 backend/internal/service/openai_codex_fingerprint.go create mode 100644 backend/internal/service/openai_codex_fingerprint_test.go diff --git a/backend/internal/service/openai_codex_fingerprint.go b/backend/internal/service/openai_codex_fingerprint.go new file mode 100644 index 0000000000..55564c534c --- /dev/null +++ b/backend/internal/service/openai_codex_fingerprint.go @@ -0,0 +1,301 @@ +package service + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" +) + +// codexFingerprintMode 控制 OAuth 账号出站请求的设备指纹收敛强度。 +// 多人共享同一 OAuth 账号时,每个用户的 Codex 客户端会携带各自不同的 +// installation_id / session_id / thread_id,上游据此判定设备数和会话数。 +// 收敛模式将这些标识改写为账号级恒定值,减少上游可见的设备/会话指纹。 +type codexFingerprintMode string + +const ( + // codexFingerprintOff 不做任何收敛,原样透传客户端标识(默认行为)。 + codexFingerprintOff codexFingerprintMode = "off" + // codexFingerprintDevice 仅收敛 installation_id 为账号级恒定值。 + // 上游看到 1 台设备 + 多会话(每用户各自的 session)。 + codexFingerprintDevice codexFingerprintMode = "device" + // codexFingerprintSession 收敛 installation_id + session_id, + // thread_id 按客户端原始 session-id 确定性派生(每个真实 Codex 会话一个独立线程)。 + // 上游看到 1 台设备 + 1 会话 + N 线程,最接近正常用户 spawn 子代理的模式。 + codexFingerprintSession codexFingerprintMode = "session" + // codexFingerprintFull 收敛所有标识:installation_id + session_id + thread_id。 + // 上游看到 1 台设备 + 1 会话 + 1 线程,最激进。 + codexFingerprintFull codexFingerprintMode = "full" +) + +const codexFingerprintModeExtraKey = "codex_fingerprint_mode" + +// GetCodexFingerprintMode 从账号 extra JSON 读取指纹收敛模式。 +// 未设置时默认 session(设备+会话收敛),显式设为 "off" 才关闭。 +func (a *Account) GetCodexFingerprintMode() codexFingerprintMode { + if a == nil || !a.IsOpenAIOAuth() { + return codexFingerprintOff + } + raw := strings.TrimSpace(a.GetExtraString(codexFingerprintModeExtraKey)) + switch codexFingerprintMode(raw) { + case codexFingerprintOff, codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull: + return codexFingerprintMode(raw) + default: + return codexFingerprintSession + } +} + +// deriveStableUUIDv4 从种子确定性派生一个 UUIDv4 格式的字符串。 +// 同一种子永远返回同一值。 +func deriveStableUUIDv4(seed string) string { + h := sha256.Sum256([]byte(seed)) + b := h[:16] + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 1 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + binary.BigEndian.Uint32(b[0:4]), + binary.BigEndian.Uint16(b[4:6]), + binary.BigEndian.Uint16(b[6:8]), + binary.BigEndian.Uint16(b[8:10]), + b[10:16]) +} + +// resolveConvergedInstallationID 返回账号级恒定的 installation_id。 +// 优先使用管理员配置的真实 device_id,无则从 accountID 确定性派生。 +func resolveConvergedInstallationID(account *Account) string { + if account == nil { + return "" + } + if deviceID := account.GetOpenAIDeviceID(); deviceID != "" { + return deviceID + } + return deriveStableUUIDv4(fmt.Sprintf("sub2api:codex-install-id:v1:%d", account.ID)) +} + +// resolveConvergedSessionID 返回账号级恒定的 session_id。 +func resolveConvergedSessionID(account *Account) string { + if account == nil { + return "" + } + return deriveStableUUIDv4(fmt.Sprintf("sub2api:codex-session-id:v1:%d", account.ID)) +} + +// resolveConvergedThreadID 按客户端原始 session-id 确定性派生 thread_id。 +// 每个真实 Codex 会话(不同客户端启动实例)获得一个独立线程, +// 模拟正常用户 spawn 子代理或开多窗口的模式。 +func resolveConvergedThreadID(account *Account, clientSessionID string) string { + if account == nil || clientSessionID == "" { + return "" + } + return deriveStableUUIDv4(fmt.Sprintf("sub2api:codex-thread-id:v1:%d:%s", account.ID, clientSessionID)) +} + +// codexFingerprintIDs 收敛后的完整 ID 集合。 +// 由 resolveCodexFingerprintIDs 一次性生成,同一个实例在头改写和体改写之间共享, +// 确保所有载体中的 turn_id 等随机字段一致。 +type codexFingerprintIDs struct { + mode codexFingerprintMode + installationID string + sessionID string + threadID string + turnID string + windowID string +} + +// resolveCodexFingerprintIDs 按收敛模式计算出站 ID 集合。 +// clientSessionID 是客户端原始的 session-id 头值(连字符形式),用于 session 模式下 +// 的 thread_id 派生——每个真实 Codex 会话得到一个独立线程。 +// 返回 nil 表示 off 模式,不需要改写。 +// 注意:包含随机生成的 turn_id,调用方必须只调用一次并共享结果给头改写和体改写。 +func resolveCodexFingerprintIDs(account *Account, clientSessionID string, mode codexFingerprintMode) *codexFingerprintIDs { + if mode == codexFingerprintOff { + return nil + } + + ids := &codexFingerprintIDs{mode: mode} + + ids.installationID = resolveConvergedInstallationID(account) + if ids.installationID == "" { + return nil + } + + switch mode { + case codexFingerprintDevice: + return ids + + case codexFingerprintSession: + ids.sessionID = resolveConvergedSessionID(account) + ids.threadID = resolveConvergedThreadID(account, clientSessionID) + if ids.threadID == "" { + ids.threadID = ids.sessionID + } + ids.turnID = uuid.Must(uuid.NewV7()).String() + ids.windowID = ids.threadID + ":0" + return ids + + case codexFingerprintFull: + ids.sessionID = resolveConvergedSessionID(account) + ids.threadID = ids.sessionID + ids.turnID = uuid.Must(uuid.NewV7()).String() + ids.windowID = ids.threadID + ":0" + return ids + } + + return nil +} + +// extractClientSessionID 从请求头中提取客户端原始的会话标识。 +// 优先取 session-id(连字符形式,Codex CLI 标准),回退到 session_id(下划线形式)。 +// 返回的值尚未被 isolateOpenAISessionID 改写,是客户端的真实标识。 +func extractClientSessionID(h http.Header) string { + if v := strings.TrimSpace(h.Get("session-id")); v != "" { + return v + } + return strings.TrimSpace(h.Get("session_id")) +} + +// resolveCodexFingerprintIDsFromRequest 从客户端原始请求头中提取 session-id, +// 结合账号配置一次性解析收敛 ID 集合。调用方应将返回的 ids 同时传给 +// applyCodexFingerprintHeaders 和 applyCodexFingerprintClientMetadata。 +func resolveCodexFingerprintIDsFromRequest(account *Account, clientHeaders http.Header) *codexFingerprintIDs { + if account == nil { + return nil + } + mode := account.GetCodexFingerprintMode() + if mode == codexFingerprintOff { + return nil + } + clientSessionID := "" + if clientHeaders != nil { + clientSessionID = extractClientSessionID(clientHeaders) + } + return resolveCodexFingerprintIDs(account, clientSessionID, mode) +} + +// applyCodexFingerprintHeaders 按预计算的收敛 ID 改写出站 HTTP 头中的设备指纹。 +// 在 buildUpstreamRequest 的白名单透传之后、enforceCodexIdentityHeaders 之前调用。 +func applyCodexFingerprintHeaders(h http.Header, ids *codexFingerprintIDs) { + if h == nil || ids == nil { + return + } + + // 所有非 off 模式都收敛 installation_id + h.Set("x-codex-installation-id", ids.installationID) + + if ids.mode == codexFingerprintDevice { + rewriteCodexTurnMetadataFields(h, map[string]any{ + "installation_id": ids.installationID, + }) + return + } + + // session / full 模式:改写所有相关头 + h.Set("x-codex-window-id", ids.windowID) + h.Set("x-client-request-id", ids.threadID) + // 连字符形式和下划线形式都改写,保证一致 + h.Set("session-id", ids.sessionID) + h.Set("session_id", ids.sessionID) + h.Set("thread-id", ids.threadID) + + rewriteCodexTurnMetadataFields(h, map[string]any{ + "installation_id": ids.installationID, + "session_id": ids.sessionID, + "thread_id": ids.threadID, + "turn_id": ids.turnID, + "window_id": ids.windowID, + "turn_started_at_unix_ms": time.Now().UnixMilli(), + }) +} + +// rewriteCodexTurnMetadataFields 解析 x-codex-turn-metadata 头中的 JSON, +// 替换指定字段后回写。保留未指定字段原样(如 sandbox、thread_source 等)。 +func rewriteCodexTurnMetadataFields(h http.Header, fields map[string]any) { + raw := strings.TrimSpace(h.Get("x-codex-turn-metadata")) + if raw == "" { + return + } + var metadata map[string]any + if err := json.Unmarshal([]byte(raw), &metadata); err != nil { + return + } + for k, v := range fields { + metadata[k] = v + } + rebuilt, err := json.Marshal(metadata) + if err != nil { + return + } + h.Set("x-codex-turn-metadata", string(rebuilt)) +} + +// applyCodexFingerprintClientMetadata 按预计算的收敛 ID 改写请求体中的 client_metadata。 +// 使用与头改写相同的 ids 实例,确保 turn_id 等随机字段一致。 +func applyCodexFingerprintClientMetadata(reqBody map[string]any, ids *codexFingerprintIDs) bool { + if reqBody == nil || ids == nil { + return false + } + + existing, _ := reqBody["client_metadata"].(map[string]any) + if existing == nil { + existing = make(map[string]any) + } + + modified := false + + if ids.installationID != "" { + existing["x-codex-installation-id"] = ids.installationID + modified = true + } + + if ids.mode == codexFingerprintDevice { + rewriteClientMetadataEmbeddedTurnMetadata(existing, map[string]any{ + "installation_id": ids.installationID, + }) + if modified { + reqBody["client_metadata"] = existing + } + return modified + } + + // session / full 模式 + existing["session_id"] = ids.sessionID + existing["thread_id"] = ids.threadID + existing["turn_id"] = ids.turnID + existing["x-codex-window-id"] = ids.windowID + + rewriteClientMetadataEmbeddedTurnMetadata(existing, map[string]any{ + "installation_id": ids.installationID, + "session_id": ids.sessionID, + "thread_id": ids.threadID, + "turn_id": ids.turnID, + "window_id": ids.windowID, + "turn_started_at_unix_ms": time.Now().UnixMilli(), + }) + + reqBody["client_metadata"] = existing + return true +} + +// rewriteClientMetadataEmbeddedTurnMetadata 改写 client_metadata 中内嵌的 +// x-codex-turn-metadata JSON 字符串里的指定字段。 +func rewriteClientMetadataEmbeddedTurnMetadata(clientMetadata map[string]any, fields map[string]any) { + raw, ok := clientMetadata["x-codex-turn-metadata"].(string) + if !ok || raw == "" { + return + } + var metadata map[string]any + if err := json.Unmarshal([]byte(raw), &metadata); err != nil { + return + } + for k, v := range fields { + metadata[k] = v + } + if rebuilt, err := json.Marshal(metadata); err == nil { + clientMetadata["x-codex-turn-metadata"] = string(rebuilt) + } +} diff --git a/backend/internal/service/openai_codex_fingerprint_test.go b/backend/internal/service/openai_codex_fingerprint_test.go new file mode 100644 index 0000000000..891fe0a9ce --- /dev/null +++ b/backend/internal/service/openai_codex_fingerprint_test.go @@ -0,0 +1,447 @@ +package service + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestOAuthAccount(id int64, extra map[string]any) *Account { + return &Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: extra, + } +} + +// --- deriveStableUUIDv4 --- + +func TestDeriveStableUUIDv4_Deterministic(t *testing.T) { + a := deriveStableUUIDv4("test-seed-1") + b := deriveStableUUIDv4("test-seed-1") + assert.Equal(t, a, b, "同一种子应返回相同结果") +} + +func TestDeriveStableUUIDv4_DifferentSeeds(t *testing.T) { + a := deriveStableUUIDv4("seed-a") + b := deriveStableUUIDv4("seed-b") + assert.NotEqual(t, a, b, "不同种子应返回不同结果") +} + +func TestDeriveStableUUIDv4_ValidFormat(t *testing.T) { + result := deriveStableUUIDv4("test-seed") + parsed, err := uuid.Parse(result) + require.NoError(t, err, "应返回合法 UUID 格式") + assert.Equal(t, uuid.Version(4), parsed.Version(), "应为 UUIDv4") + assert.Equal(t, uuid.RFC4122, parsed.Variant(), "应为 RFC4122 变体") +} + +// --- GetCodexFingerprintMode --- + +func TestGetCodexFingerprintMode(t *testing.T) { + tests := []struct { + name string + account *Account + expected codexFingerprintMode + }{ + {"nil 账号", nil, codexFingerprintOff}, + {"非 OAuth 账号", &Account{Platform: PlatformOpenAI, Type: "api_key"}, codexFingerprintOff}, + {"无 extra 默认 session", newTestOAuthAccount(1, nil), codexFingerprintSession}, + {"空值默认 session", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: ""}), codexFingerprintSession}, + {"非法值默认 session", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "invalid"}), codexFingerprintSession}, + {"显式 off", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "off"}), codexFingerprintOff}, + {"device", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "device"}), codexFingerprintDevice}, + {"session", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "session"}), codexFingerprintSession}, + {"full", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "full"}), codexFingerprintFull}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.account.GetCodexFingerprintMode()) + }) + } +} + +// --- resolveConvergedInstallationID --- + +func TestResolveConvergedInstallationID_UsesDeviceID(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{"openai_device_id": "real-device-id"}) + assert.Equal(t, "real-device-id", resolveConvergedInstallationID(account)) +} + +func TestResolveConvergedInstallationID_DerivesFromAccountID(t *testing.T) { + account := newTestOAuthAccount(42, nil) + result := resolveConvergedInstallationID(account) + _, err := uuid.Parse(result) + require.NoError(t, err, "派生值应为合法 UUID") + assert.Equal(t, result, resolveConvergedInstallationID(account), "确定性") +} + +func TestResolveConvergedInstallationID_DifferentAccounts(t *testing.T) { + a := resolveConvergedInstallationID(newTestOAuthAccount(1, nil)) + b := resolveConvergedInstallationID(newTestOAuthAccount(2, nil)) + assert.NotEqual(t, a, b) +} + +// --- resolveConvergedThreadID --- + +func TestResolveConvergedThreadID_PerClientSession(t *testing.T) { + account := newTestOAuthAccount(1, nil) + a := resolveConvergedThreadID(account, "session-aaa") + b := resolveConvergedThreadID(account, "session-bbb") + assert.NotEqual(t, a, b, "不同客户端 session 应得到不同 thread_id") +} + +func TestResolveConvergedThreadID_Deterministic(t *testing.T) { + account := newTestOAuthAccount(1, nil) + a := resolveConvergedThreadID(account, "session-aaa") + b := resolveConvergedThreadID(account, "session-aaa") + assert.Equal(t, a, b, "同一客户端 session 应得到相同 thread_id") +} + +func TestResolveConvergedThreadID_EmptySession(t *testing.T) { + account := newTestOAuthAccount(1, nil) + assert.Equal(t, "", resolveConvergedThreadID(account, "")) +} + +// --- off 模式:resolveCodexFingerprintIDsFromRequest 返回 nil --- + +func TestResolveCodexFingerprintIDsFromRequest_ExplicitOff(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "off"}) + ids := resolveCodexFingerprintIDsFromRequest(account, nil) + assert.Nil(t, ids, "显式 off 模式应返回 nil") +} + +func TestResolveCodexFingerprintIDsFromRequest_DefaultIsSession(t *testing.T) { + account := newTestOAuthAccount(1, nil) + ids := resolveCodexFingerprintIDsFromRequest(account, nil) + require.NotNil(t, ids, "无 extra 默认 session 模式,应返回非 nil") + assert.Equal(t, codexFingerprintSession, ids.mode) + assert.NotEmpty(t, ids.sessionID) + assert.NotEmpty(t, ids.turnID) +} + +// --- applyCodexFingerprintHeaders: off 模式 --- + +func TestApplyCodexFingerprintHeaders_OffMode(t *testing.T) { + h := http.Header{} + h.Set("x-codex-installation-id", "original-install-id") + h.Set("x-codex-window-id", "original-window-id") + + applyCodexFingerprintHeaders(h, nil) + + assert.Equal(t, "original-install-id", h.Get("x-codex-installation-id"), "nil ids 不改写") + assert.Equal(t, "original-window-id", h.Get("x-codex-window-id"), "nil ids 不改写") +} + +// --- applyCodexFingerprintHeaders: device 模式 --- + +func TestApplyCodexFingerprintHeaders_DeviceMode(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{ + codexFingerprintModeExtraKey: "device", + "openai_device_id": "converged-device", + }) + turnMetadata := `{"installation_id":"user-install","session_id":"user-session","sandbox":"seccomp"}` + h := http.Header{} + h.Set("x-codex-installation-id", "user-install") + h.Set("x-codex-window-id", "user-window:0") + h.Set("x-codex-turn-metadata", turnMetadata) + + ids := resolveCodexFingerprintIDsFromRequest(account, nil) + applyCodexFingerprintHeaders(h, ids) + + assert.Equal(t, "converged-device", h.Get("x-codex-installation-id"), "installation_id 应收敛") + assert.Equal(t, "user-window:0", h.Get("x-codex-window-id"), "device 模式不改写 window_id") + + var meta map[string]any + require.NoError(t, json.Unmarshal([]byte(h.Get("x-codex-turn-metadata")), &meta)) + assert.Equal(t, "converged-device", meta["installation_id"]) + assert.Equal(t, "user-session", meta["session_id"], "device 模式不改写 session_id") + assert.Equal(t, "seccomp", meta["sandbox"], "非指纹字段保留原样") +} + +// --- applyCodexFingerprintHeaders: session 模式 --- + +func TestApplyCodexFingerprintHeaders_SessionMode(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{ + codexFingerprintModeExtraKey: "session", + }) + clientHeaders := http.Header{} + clientHeaders.Set("session-id", "client-session-aaa") + + turnMetadata := `{"installation_id":"user-install","session_id":"user-session","thread_id":"user-thread","turn_id":"user-turn","window_id":"user-thread:0","sandbox":"seccomp","thread_source":"user"}` + h := http.Header{} + h.Set("x-codex-installation-id", "user-install") + h.Set("x-codex-window-id", "user-thread:0") + h.Set("x-codex-turn-metadata", turnMetadata) + h.Set("x-client-request-id", "user-thread") + + ids := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + applyCodexFingerprintHeaders(h, ids) + + convergedInstall := resolveConvergedInstallationID(account) + convergedSession := resolveConvergedSessionID(account) + convergedThread := resolveConvergedThreadID(account, "client-session-aaa") + + assert.Equal(t, convergedInstall, h.Get("x-codex-installation-id")) + assert.Equal(t, convergedSession, h.Get("session-id")) + assert.Equal(t, convergedSession, h.Get("session_id"), "下划线形式也应被改写") + assert.Equal(t, convergedThread, h.Get("thread-id")) + assert.Equal(t, convergedThread, h.Get("x-client-request-id")) + assert.Equal(t, convergedThread+":0", h.Get("x-codex-window-id")) + + var meta map[string]any + require.NoError(t, json.Unmarshal([]byte(h.Get("x-codex-turn-metadata")), &meta)) + assert.Equal(t, convergedInstall, meta["installation_id"]) + assert.Equal(t, convergedSession, meta["session_id"]) + assert.Equal(t, convergedThread, meta["thread_id"]) + assert.NotEqual(t, "user-turn", meta["turn_id"], "turn_id 应被新生成的值替换") + assert.Equal(t, "seccomp", meta["sandbox"], "sandbox 保留原样") + assert.Equal(t, "user", meta["thread_source"], "thread_source 保留原样") +} + +// --- session 模式:不同客户端得到不同 thread --- + +func TestApplyCodexFingerprintHeaders_SessionMode_DifferentClients(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{ + codexFingerprintModeExtraKey: "session", + }) + + makeTurnMeta := func() string { + return `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","window_id":"x:0"}` + } + + clientA := http.Header{} + clientA.Set("session-id", "client-A") + idsA := resolveCodexFingerprintIDsFromRequest(account, clientA) + hA := http.Header{} + hA.Set("x-codex-turn-metadata", makeTurnMeta()) + applyCodexFingerprintHeaders(hA, idsA) + + clientB := http.Header{} + clientB.Set("session-id", "client-B") + idsB := resolveCodexFingerprintIDsFromRequest(account, clientB) + hB := http.Header{} + hB.Set("x-codex-turn-metadata", makeTurnMeta()) + applyCodexFingerprintHeaders(hB, idsB) + + assert.Equal(t, hA.Get("session-id"), hB.Get("session-id"), "session_id 应相同") + assert.NotEqual(t, hA.Get("thread-id"), hB.Get("thread-id"), "不同客户端 thread_id 应不同") + assert.NotEqual(t, hA.Get("x-codex-window-id"), hB.Get("x-codex-window-id"), "不同客户端 window_id 应不同") + assert.Equal(t, hA.Get("x-codex-installation-id"), hB.Get("x-codex-installation-id")) +} + +// --- full 模式 --- + +func TestApplyCodexFingerprintHeaders_FullMode(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{ + codexFingerprintModeExtraKey: "full", + }) + convergedSession := resolveConvergedSessionID(account) + + clientA := http.Header{} + clientA.Set("session-id", "client-A") + idsA := resolveCodexFingerprintIDsFromRequest(account, clientA) + hA := http.Header{} + hA.Set("x-codex-turn-metadata", `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","window_id":"x:0"}`) + applyCodexFingerprintHeaders(hA, idsA) + + clientB := http.Header{} + clientB.Set("session-id", "client-B") + idsB := resolveCodexFingerprintIDsFromRequest(account, clientB) + hB := http.Header{} + hB.Set("x-codex-turn-metadata", `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","window_id":"x:0"}`) + applyCodexFingerprintHeaders(hB, idsB) + + assert.Equal(t, hA.Get("thread-id"), hB.Get("thread-id"), "full 模式 thread_id 应相同") + assert.Equal(t, convergedSession, hA.Get("thread-id"), "full 模式 thread_id 应等于 session_id") + assert.Equal(t, hA.Get("x-codex-window-id"), hB.Get("x-codex-window-id"), "full 模式 window_id 应相同") +} + +// --- H1 修复验证:头和体的 turn_id 一致性 --- + +func TestFingerprintIDs_HeaderAndBody_TurnID_Consistent(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{ + codexFingerprintModeExtraKey: "session", + }) + clientHeaders := http.Header{} + clientHeaders.Set("session-id", "client-session-xyz") + + ids := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + require.NotNil(t, ids) + + // 头改写 + h := http.Header{} + h.Set("x-codex-turn-metadata", `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","window_id":"x:0"}`) + applyCodexFingerprintHeaders(h, ids) + + // 体改写(使用同一份 ids) + reqBody := map[string]any{ + "client_metadata": map[string]any{ + "x-codex-installation-id": "x", + "session_id": "x", + "turn_id": "x", + "x-codex-turn-metadata": `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","window_id":"x:0"}`, + }, + } + applyCodexFingerprintClientMetadata(reqBody, ids) + + // 从头 turn-metadata JSON 提取 turn_id + var headerMeta map[string]any + require.NoError(t, json.Unmarshal([]byte(h.Get("x-codex-turn-metadata")), &headerMeta)) + headerTurnID := headerMeta["turn_id"].(string) + + // 从体 client_metadata 提取 turn_id + cm := reqBody["client_metadata"].(map[string]any) + bodyTurnID := cm["turn_id"].(string) + + // 从体内嵌 turn-metadata JSON 提取 turn_id + var bodyMeta map[string]any + require.NoError(t, json.Unmarshal([]byte(cm["x-codex-turn-metadata"].(string)), &bodyMeta)) + bodyEmbeddedTurnID := bodyMeta["turn_id"].(string) + + assert.Equal(t, headerTurnID, bodyTurnID, "头和体的 turn_id 必须一致") + assert.Equal(t, headerTurnID, bodyEmbeddedTurnID, "头和体内嵌 turn-metadata 的 turn_id 必须一致") + assert.Equal(t, ids.turnID, headerTurnID, "所有 turn_id 都应来自同一份 ids") +} + +// --- applyCodexFingerprintClientMetadata --- + +func TestApplyCodexFingerprintClientMetadata_OffMode(t *testing.T) { + reqBody := map[string]any{ + "client_metadata": map[string]any{ + "x-codex-installation-id": "original", + }, + } + modified := applyCodexFingerprintClientMetadata(reqBody, nil) + assert.False(t, modified, "nil ids 不改写") +} + +func TestApplyCodexFingerprintClientMetadata_DeviceMode(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{ + codexFingerprintModeExtraKey: "device", + "openai_device_id": "converged-device", + }) + ids := resolveCodexFingerprintIDsFromRequest(account, nil) + require.NotNil(t, ids) + + embeddedMeta := `{"installation_id":"x","session_id":"user-session","sandbox":"seccomp"}` + reqBody := map[string]any{ + "client_metadata": map[string]any{ + "x-codex-installation-id": "original-install", + "session_id": "user-session", + "x-codex-turn-metadata": embeddedMeta, + }, + } + + modified := applyCodexFingerprintClientMetadata(reqBody, ids) + require.True(t, modified) + + cm := reqBody["client_metadata"].(map[string]any) + assert.Equal(t, "converged-device", cm["x-codex-installation-id"]) + assert.Equal(t, "user-session", cm["session_id"], "device 模式不改 session_id") + + var meta map[string]any + require.NoError(t, json.Unmarshal([]byte(cm["x-codex-turn-metadata"].(string)), &meta)) + assert.Equal(t, "converged-device", meta["installation_id"]) + assert.Equal(t, "seccomp", meta["sandbox"], "非指纹字段保留原样") +} + +func TestApplyCodexFingerprintClientMetadata_SessionMode(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{ + codexFingerprintModeExtraKey: "session", + }) + clientHeaders := http.Header{} + clientHeaders.Set("session-id", "client-session-aaa") + + ids := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + require.NotNil(t, ids) + + embeddedMeta := `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","window_id":"x:0","sandbox":"seccomp"}` + reqBody := map[string]any{ + "client_metadata": map[string]any{ + "x-codex-installation-id": "original-install", + "session_id": "original-session", + "x-codex-turn-metadata": embeddedMeta, + }, + } + + modified := applyCodexFingerprintClientMetadata(reqBody, ids) + require.True(t, modified) + + cm := reqBody["client_metadata"].(map[string]any) + convergedInstall := resolveConvergedInstallationID(account) + convergedSession := resolveConvergedSessionID(account) + convergedThread := resolveConvergedThreadID(account, "client-session-aaa") + + assert.Equal(t, convergedInstall, cm["x-codex-installation-id"]) + assert.Equal(t, convergedSession, cm["session_id"]) + assert.Equal(t, convergedThread, cm["thread_id"]) + assert.Equal(t, convergedThread+":0", cm["x-codex-window-id"]) + + var meta map[string]any + require.NoError(t, json.Unmarshal([]byte(cm["x-codex-turn-metadata"].(string)), &meta)) + assert.Equal(t, convergedInstall, meta["installation_id"]) + assert.Equal(t, convergedSession, meta["session_id"]) + assert.Equal(t, "seccomp", meta["sandbox"], "非指纹字段保留原样") +} + +func TestApplyCodexFingerprintClientMetadata_FullMode(t *testing.T) { + account := newTestOAuthAccount(1, map[string]any{ + codexFingerprintModeExtraKey: "full", + }) + clientHeaders := http.Header{} + clientHeaders.Set("session-id", "any-client") + + ids := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + require.NotNil(t, ids) + + reqBody := map[string]any{ + "client_metadata": map[string]any{ + "session_id": "x", + "thread_id": "x", + "x-codex-turn-metadata": `{"installation_id":"x","session_id":"x","thread_id":"x","turn_id":"x","window_id":"x:0"}`, + }, + } + + modified := applyCodexFingerprintClientMetadata(reqBody, ids) + require.True(t, modified) + + cm := reqBody["client_metadata"].(map[string]any) + convergedSession := resolveConvergedSessionID(account) + + assert.Equal(t, convergedSession, cm["session_id"]) + assert.Equal(t, convergedSession, cm["thread_id"], "full 模式 thread_id 应等于 session_id") +} + +// --- extractClientSessionID --- + +func TestExtractClientSessionID(t *testing.T) { + tests := []struct { + name string + headers http.Header + expected string + }{ + {"连字符形式优先", func() http.Header { + h := http.Header{} + h.Set("session-id", "hyphen-form") + h.Set("session_id", "underscore-form") + return h + }(), "hyphen-form"}, + {"回退到下划线形式", func() http.Header { + h := http.Header{} + h.Set("session_id", "underscore-form") + return h + }(), "underscore-form"}, + {"都没有", http.Header{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, extractClientSessionID(tt.headers)) + }) + } +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 34c92dfe89..f7eb759346 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -414,6 +414,24 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if !isCompactRequest && applyCodexClientMetadata(decoded, account) { markDecodedModified() } + // 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。 + // fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。 + if !isCompactRequest { + var clientHeaders http.Header + if c != nil && c.Request != nil { + clientHeaders = c.Request.Header + } + fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders) + if fpIDs != nil { + if applyCodexFingerprintClientMetadata(decoded, fpIDs) { + markDecodedModified() + } + } + // 将 fpIDs 存入 gin context,供 buildUpstreamRequest 中头改写使用 + if c != nil && fpIDs != nil { + c.Set("codex_fingerprint_ids", fpIDs) + } + } if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } @@ -1119,6 +1137,15 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", codexCLIUserAgent) } + // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。 + if account.Type == AccountTypeOAuth && c != nil { + if fpIDs, ok := c.Get("codex_fingerprint_ids"); ok { + if ids, ok := fpIDs.(*codexFingerprintIDs); ok { + applyCodexFingerprintHeaders(req.Header, ids) + } + } + } + // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。 // 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。 if account.Type == AccountTypeOAuth { diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index ad9998695d..e7d2517928 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -913,6 +913,24 @@ + +
+
+ + +
+
+

+ {{ t('admin.accounts.openai.codexFingerprintModeDesc') }} +

+ +
+
+ +
(OPENAI_WS_MODE_OFF const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyAppServerEnabled = ref(false) +type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full' +const codexFingerprintMode = ref('session') +const codexFingerprintModeOptions = computed(() => [ + { value: 'off' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintOff') }, + { value: 'device' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintDevice') }, + { value: 'session' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintSession') }, + { value: 'full' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintFull') }, +]) type AnthropicAPIKeyAuthScheme = 'x_api_key' | 'authorization_bearer' const anthropicPassthroughEnabled = ref(false) const anthropicAPIKeyAuthScheme = ref('x_api_key') @@ -4715,6 +4741,7 @@ const resetForm = () => { openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false codexCLIOnlyAppServerEnabled.value = false + codexFingerprintMode.value = 'session' anthropicPassthroughEnabled.value = false anthropicAPIKeyAuthScheme.value = 'x_api_key' webSearchEmulationMode.value = 'default' @@ -4813,6 +4840,11 @@ const buildOpenAIExtra = (base?: Record): Record
+ +
+
+
+ +

+ {{ t('admin.accounts.openai.codexFingerprintModeDesc') }} +

+
+
+