mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:57:53 +08:00
feat: Codex OAuth 设备指纹收敛,减少上游可见的设备数和会话数
多人共享同一 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 等随机字段一致。
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -913,6 +913,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex 指纹收敛模式(仅 OpenAI OAuth) -->
|
||||
<div v-if="allOpenAIOAuth" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexFingerprintMode') }}</label>
|
||||
<input
|
||||
v-model="enableCodexFingerprintMode"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div :class="!enableCodexFingerprintMode && 'pointer-events-none opacity-50'">
|
||||
<p class="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.codexFingerprintModeDesc') }}
|
||||
</p>
|
||||
<Select v-model="codexFingerprintMode" data-testid="bulk-codex-fingerprint-mode-select" :options="codexFingerprintModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upstream billing auto probe (any API-key platform) -->
|
||||
<div v-if="allBillingProbeCapable" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -1515,6 +1533,15 @@ const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OF
|
||||
const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled')
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
const codexCLIOnlyAppServerEnabled = ref(false)
|
||||
type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full'
|
||||
const enableCodexFingerprintMode = ref(false)
|
||||
const codexFingerprintMode = ref<CodexFingerprintMode>('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') },
|
||||
])
|
||||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||
const openAICompactModelMappings = ref<ModelMapping[]>([])
|
||||
const rpmLimitEnabled = ref(false)
|
||||
@@ -1800,6 +1827,15 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
extra.codex_cli_only_allow_app_server = codexCLIOnlyAppServerEnabled.value
|
||||
}
|
||||
|
||||
if (enableCodexFingerprintMode.value) {
|
||||
const extra = ensureExtra()
|
||||
if (codexFingerprintMode.value !== 'session') {
|
||||
extra.codex_fingerprint_mode = codexFingerprintMode.value
|
||||
} else {
|
||||
delete extra.codex_fingerprint_mode
|
||||
}
|
||||
}
|
||||
|
||||
if (enableOpenAICompactMode.value) {
|
||||
const extra = ensureExtra()
|
||||
extra.openai_compact_mode = openAICompactMode.value
|
||||
@@ -1910,6 +1946,7 @@ const handleSubmit = async () => {
|
||||
enableUpstreamBillingAutoProbe.value ||
|
||||
enableCodexCLIOnly.value ||
|
||||
enableCodexCLIOnlyAppServer.value ||
|
||||
enableCodexFingerprintMode.value ||
|
||||
enableOpenAICompactMode.value ||
|
||||
enableOpenAICompactModelMapping.value ||
|
||||
enableRpmLimit.value ||
|
||||
@@ -2044,6 +2081,8 @@ watch(
|
||||
enableUpstreamBillingAutoProbe.value = false
|
||||
enableCodexCLIOnly.value = false
|
||||
enableCodexCLIOnlyAppServer.value = false
|
||||
enableCodexFingerprintMode.value = false
|
||||
codexFingerprintMode.value = 'session'
|
||||
enableOpenAICompactMode.value = false
|
||||
enableOpenAICompactModelMapping.value = false
|
||||
enableRpmLimit.value = false
|
||||
|
||||
@@ -3003,6 +3003,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex 指纹收敛模式(仅 OpenAI OAuth) -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexFingerprintMode') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.codexFingerprintModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-52 flex-shrink-0">
|
||||
<Select v-model="codexFingerprintMode" data-testid="create-codex-fingerprint-mode-select" :options="codexFingerprintModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Compact 能力配置 -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
|
||||
@@ -3836,6 +3854,14 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
const codexCLIOnlyAppServerEnabled = ref(false)
|
||||
type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full'
|
||||
const codexFingerprintMode = ref<CodexFingerprintMode>('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<AnthropicAPIKeyAuthScheme>('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<string, unknown>): Record<string, unknow
|
||||
} else {
|
||||
delete extra.codex_cli_only_allow_app_server
|
||||
}
|
||||
if (codexFingerprintMode.value !== 'session') {
|
||||
extra.codex_fingerprint_mode = codexFingerprintMode.value
|
||||
} else {
|
||||
delete extra.codex_fingerprint_mode
|
||||
}
|
||||
if (openAICompactMode.value !== 'auto') {
|
||||
extra.openai_compact_mode = openAICompactMode.value
|
||||
} else {
|
||||
|
||||
@@ -2001,6 +2001,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex 指纹收敛模式(仅 OpenAI OAuth) -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && account?.type === 'oauth'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexFingerprintMode') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.codexFingerprintModeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-52 flex-shrink-0">
|
||||
<Select v-model="codexFingerprintMode" data-testid="edit-codex-fingerprint-mode-select" :options="codexFingerprintModeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI 订阅档位手动覆盖(Plus/Pro/Free),仅 OAuth 非影子账号 -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && account?.type === 'oauth' && !isSparkShadow"
|
||||
@@ -2961,6 +2979,8 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
const codexCLIOnlyAppServerEnabled = ref(false)
|
||||
type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full'
|
||||
const codexFingerprintMode = ref<CodexFingerprintMode>('session')
|
||||
type CodexImageToolMode = 'inherit' | 'enabled' | 'disabled' | 'block'
|
||||
const codexImageToolMode = ref<CodexImageToolMode>('inherit')
|
||||
type AnthropicAPIKeyAuthScheme = 'x_api_key' | 'authorization_bearer'
|
||||
@@ -2992,6 +3012,13 @@ const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null)
|
||||
const editWeeklyResetDay = ref<number | null>(null)
|
||||
const editWeeklyResetHour = ref<number | null>(null)
|
||||
const editResetTimezone = ref<string | null>(null)
|
||||
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') },
|
||||
])
|
||||
|
||||
const openAIWSModeOptions = computed(() => [
|
||||
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
|
||||
{ value: OPENAI_WS_MODE_CTX_POOL, label: t('admin.accounts.openai.wsModeCtxPool') },
|
||||
@@ -3413,6 +3440,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
codexCLIOnlyAppServerEnabled.value = false
|
||||
codexFingerprintMode.value = 'session'
|
||||
codexImageToolMode.value = 'inherit'
|
||||
anthropicPassthroughEnabled.value = false
|
||||
anthropicAPIKeyAuthScheme.value = 'x_api_key'
|
||||
@@ -3464,6 +3492,12 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
codexCLIOnlyAppServerEnabled.value =
|
||||
extra?.codex_cli_only_allow_app_server === true
|
||||
}
|
||||
if (newAccount.type === 'oauth') {
|
||||
const fpMode = extra?.codex_fingerprint_mode as string | undefined
|
||||
codexFingerprintMode.value = (['off', 'device', 'session', 'full'].includes(fpMode || '')
|
||||
? fpMode as CodexFingerprintMode
|
||||
: 'session')
|
||||
}
|
||||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||
const compactMappings = credentials?.compact_model_mapping as Record<string, string> | undefined
|
||||
if (compactMappings && typeof compactMappings === 'object') {
|
||||
@@ -4809,6 +4843,15 @@ const handleSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 指纹收敛模式:默认 session,不写入;非默认值显式写入(包括 off)
|
||||
if (props.account.type === 'oauth') {
|
||||
if (codexFingerprintMode.value !== 'session') {
|
||||
newExtra.codex_fingerprint_mode = codexFingerprintMode.value
|
||||
} else {
|
||||
delete newExtra.codex_fingerprint_mode
|
||||
}
|
||||
}
|
||||
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
|
||||
@@ -573,6 +573,12 @@ export default {
|
||||
codexCLIOnlyAppServer: 'Allow Codex app-server clients',
|
||||
codexCLIOnlyAppServerDesc:
|
||||
"Effective only when the switch above is on. When enabled, this account also allows third-party clients that embed the Codex engine over the app-server protocol (e.g. Claude Code's codex plugin); they still pass the global engine-fingerprint gate. OR-combined with the global app-server toggle.",
|
||||
codexFingerprintMode: 'Codex fingerprint convergence',
|
||||
codexFingerprintModeDesc: 'When multiple users share the same OAuth account, converge device/session identifiers to account-level stable values to reduce upstream-visible device and session count. Off = pass through client identifiers as-is.',
|
||||
codexFingerprintOff: 'Off (passthrough)',
|
||||
codexFingerprintDevice: 'Device only',
|
||||
codexFingerprintSession: 'Device + Session (recommended)',
|
||||
codexFingerprintFull: 'Full convergence',
|
||||
codexImageTool: 'Codex image bridge policy',
|
||||
codexImageToolDesc:
|
||||
'Controls the hosted image_generation bridge and client-declared image tools on Codex /responses text requests. Hosted auto-injection applies only to non-Responses Lite requests. Account policy takes precedence over channel and global settings; standalone image-generation endpoints are unaffected.',
|
||||
|
||||
@@ -643,6 +643,12 @@ export default {
|
||||
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
|
||||
codexCLIOnlyAppServer: '允许 Codex app-server 客户端',
|
||||
codexCLIOnlyAppServerDesc: '仅在上方开关开启时生效。开启后本账号额外放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件),仍需通过全局引擎指纹门;与全局 app-server 开关取 OR(任一开即放行)。',
|
||||
codexFingerprintMode: 'Codex 指纹收敛',
|
||||
codexFingerprintModeDesc: '多人共享同一 OAuth 账号时,将各用户的设备/会话标识收敛为账号级恒定值,减少上游可见的设备数和会话数。关闭时原样透传客户端标识。',
|
||||
codexFingerprintOff: '关闭(透传)',
|
||||
codexFingerprintDevice: '仅设备',
|
||||
codexFingerprintSession: '设备+会话(推荐)',
|
||||
codexFingerprintFull: '完全收敛',
|
||||
codexImageTool: 'Codex 图片桥接策略',
|
||||
codexImageToolDesc:
|
||||
'统一控制 Codex /responses 文本请求的 hosted image_generation 桥接和客户端图片工具声明。hosted 工具自动注入仅适用于非 Responses Lite 请求;账号级策略优先于渠道和全局配置,不影响独立图片生成接口。',
|
||||
|
||||
Reference in New Issue
Block a user