Files
sub2api/backend/internal/service/openai_codex_fingerprint.go
T
shaw fce41e318f fix(openai): make Codex fingerprint convergence opt-in and cover passthrough
Default codex_fingerprint_mode to off. v0.1.175 treated a missing key as
"session", so upgrading silently rewrote installation/session/thread/turn/
window identifiers for every existing OAuth account that had never configured
this field. The quota regressions in #5555, #5556 and #5582 line up with that
version boundary, with A/B reports that rolling back to v0.1.173 restores
quota. Convergence is now explicit opt-in (#5610).

Only accounts that never set the field change behaviour; explicit off /
device / session / full keep working exactly as configured. That required
flipping the persistence condition in all three account modals from
"!== 'session'" to "!== 'off'": the old rule deleted the key when it equalled
the default, which after the flip would have silently discarded an
administrator's explicit opt-in to session.

Also extend convergence to the passthrough path, which previously left client
identifiers untouched:

- resolve the ids once in forwardOpenAIPassthrough and rewrite
  client_metadata on the raw bytes (gjson extract + sjson splice) because
  passthrough is a hot path that must not fully unmarshal multi-MB bodies;
  a shared core keeps the raw and map variants from drifting
- both request builders apply the staged ids at the same relative position
  (after session isolation, before identity enforcement) so headers and body
  share one id set and turn_id stays consistent
- stage the ids unconditionally, including nil: a failover from a converged
  account to an off account must not leave the previous account's ids behind
2026-08-15 16:35:26 +08:00

394 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"crypto/sha256"
"encoding/binary"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// codexFingerprintIDsContextKey 是暂存在 gin context 的收敛 ID 集合键。
// 由 Forward(非透传)或 forwardOpenAIPassthrough(透传)解析后写入,请求
// 构造器读取用于出站头改写——请求体与出站头必须共享同一份 IDs,保证
// turn_id 等随机字段一致。
const codexFingerprintIDsContextKey = "codex_fingerprint_ids"
// stageCodexFingerprintIDs 将本 attempt 解析出的收敛 ID 暂存到 gin context。
// 必须无条件覆写(含 nil):failover 从收敛账号切到 off 账号时,上一账号的
// IDs 不得残留并被误应用到新账号的出站头(typed-nil 由应用侧 nil 守卫吸收)。
func stageCodexFingerprintIDs(c *gin.Context, ids *codexFingerprintIDs) {
if c != nil {
c.Set(codexFingerprintIDsContextKey, ids)
}
}
// applyStagedCodexFingerprintHeaders 读取 context 暂存的收敛 ID 并改写出站头。
// 非透传与透传两个请求构造器共用本函数,防止应用语义漂移。仅 OAuth 账号
// 生效(stale 键在账号类型混合 failover 下由该门挡住)。
func applyStagedCodexFingerprintHeaders(c *gin.Context, account *Account, h http.Header) {
if c == nil || account == nil || account.Type != AccountTypeOAuth {
return
}
value, ok := c.Get(codexFingerprintIDsContextKey)
if !ok {
return
}
if ids, ok := value.(*codexFingerprintIDs); ok {
applyCodexFingerprintHeaders(h, ids)
}
}
// codexFingerprintMode 控制 OAuth 账号出站请求的设备指纹收敛强度。
// 多人共享同一 OAuth 账号时,每个用户的 Codex 客户端会携带各自不同的
// installation_id / session_id / thread_id,上游据此判定设备数和会话数。
// 收敛模式将这些标识改写为账号级恒定值,减少上游可见的设备/会话指纹。
type codexFingerprintMode string
const (
// codexFingerprintOff 不做任何收敛,原样透传客户端标识。
// 这是默认值:收敛是显式 opt-in 的(见 GetCodexFingerprintMode)。
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 读取指纹收敛模式。
//
// **收敛是显式 opt-in**:未设置、空值或非法值一律按 off 处理,只有管理员
// 明确配置 device / session / full 才收敛。
//
// 历史:v0.1.175(#5553)把缺省值当作 session,导致升级后存量 OAuth 账号
// (普遍没有这个 extra 键)的每个非透传请求都被静默改写 installation /
// session / thread / turn / window 五类标识;#5555、#5556、#5582 报告的额度
// 缩水都卡在该版本边界,并有"回退 v0.1.173 即恢复"与"新账号开收敛后降额"
// 的 A/B 实测。上游的配额判定策略不可观测,因此这里取兼容安全的一侧:
// 不显式 opt-in 就保持 v0.1.175 之前的客户端身份(#5610)。
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 codexFingerprintOff
}
}
// 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)
}
if !applyCodexFingerprintToClientMetadataMap(existing, ids) {
return false
}
reqBody["client_metadata"] = existing
return true
}
// applyCodexFingerprintToClientMetadataMap 是 client_metadata 改写的共享核心,
// map 版(非透传,body 已解码)与 raw 字节版(透传热路径)都经由它,保证两条
// 路径的收敛语义永不漂移。
func applyCodexFingerprintToClientMetadataMap(existing map[string]any, ids *codexFingerprintIDs) bool {
if existing == nil || ids == nil {
return false
}
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,
})
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(),
})
return true
}
// applyCodexFingerprintClientMetadataRaw 在原始 JSON 字节上改写 client_metadata,
// 供透传路径使用——透传是热路径,禁止对可能高达数十 MB 的 body 做全量
// Unmarshal(见 forwardOpenAIPassthrough 的轻量提取注释)。实现为:gjson 提取
// client_metadata 小对象单独解码,经共享核心改写后 sjson 一次性拼回,body
// 其余字节原样保留。语义与 applyCodexFingerprintClientMetadata 逐点一致
// (含"非对象值整体替换为收敛集合"的行为)。
func applyCodexFingerprintClientMetadataRaw(body []byte, ids *codexFingerprintIDs) ([]byte, bool, error) {
if len(body) == 0 || ids == nil {
return body, false, nil
}
// 非 JSON 对象的 body(数组/标量/畸形)没有 client_metadata 语义,
// sjson 在这类根上写字段会改写整体结构,直接放行保持原样。
if !gjson.ParseBytes(body).IsObject() {
return body, false, nil
}
existing := map[string]any{}
if cm := gjson.GetBytes(body, "client_metadata"); cm.IsObject() {
if err := json.Unmarshal([]byte(cm.Raw), &existing); err != nil {
return body, false, fmt.Errorf("decode client_metadata for fingerprint: %w", err)
}
}
if !applyCodexFingerprintToClientMetadataMap(existing, ids) {
return body, false, nil
}
raw, err := json.Marshal(existing)
if err != nil {
return body, false, fmt.Errorf("encode converged client_metadata: %w", err)
}
next, err := sjson.SetRawBytes(body, "client_metadata", raw)
if err != nil {
return body, false, fmt.Errorf("splice converged client_metadata: %w", err)
}
return next, true, nil
}
// 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)
}
}