mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:28:39 +08:00
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
This commit is contained in:
@@ -9,9 +9,43 @@ import (
|
||||
"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,上游据此判定设备数和会话数。
|
||||
@@ -19,7 +53,8 @@ import (
|
||||
type codexFingerprintMode string
|
||||
|
||||
const (
|
||||
// codexFingerprintOff 不做任何收敛,原样透传客户端标识(默认行为)。
|
||||
// codexFingerprintOff 不做任何收敛,原样透传客户端标识。
|
||||
// 这是默认值:收敛是显式 opt-in 的(见 GetCodexFingerprintMode)。
|
||||
codexFingerprintOff codexFingerprintMode = "off"
|
||||
// codexFingerprintDevice 仅收敛 installation_id 为账号级恒定值。
|
||||
// 上游看到 1 台设备 + 多会话(每用户各自的 session)。
|
||||
@@ -36,7 +71,16 @@ const (
|
||||
const codexFingerprintModeExtraKey = "codex_fingerprint_mode"
|
||||
|
||||
// GetCodexFingerprintMode 从账号 extra JSON 读取指纹收敛模式。
|
||||
// 未设置时默认 session(设备+会话收敛),显式设为 "off" 才关闭。
|
||||
//
|
||||
// **收敛是显式 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
|
||||
@@ -46,7 +90,7 @@ func (a *Account) GetCodexFingerprintMode() codexFingerprintMode {
|
||||
case codexFingerprintOff, codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull:
|
||||
return codexFingerprintMode(raw)
|
||||
default:
|
||||
return codexFingerprintSession
|
||||
return codexFingerprintOff
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +289,21 @@ func applyCodexFingerprintClientMetadata(reqBody map[string]any, ids *codexFinge
|
||||
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 != "" {
|
||||
@@ -256,9 +315,6 @@ func applyCodexFingerprintClientMetadata(reqBody map[string]any, ids *codexFinge
|
||||
rewriteClientMetadataEmbeddedTurnMetadata(existing, map[string]any{
|
||||
"installation_id": ids.installationID,
|
||||
})
|
||||
if modified {
|
||||
reqBody["client_metadata"] = existing
|
||||
}
|
||||
return modified
|
||||
}
|
||||
|
||||
@@ -276,11 +332,47 @@ func applyCodexFingerprintClientMetadata(reqBody map[string]any, ids *codexFinge
|
||||
"window_id": ids.windowID,
|
||||
"turn_started_at_unix_ms": time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
reqBody["client_metadata"] = existing
|
||||
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) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -51,9 +54,11 @@ func TestGetCodexFingerprintMode(t *testing.T) {
|
||||
}{
|
||||
{"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},
|
||||
// 收敛是显式 opt-in:缺省/空/非法一律 off(#5610)。存量账号普遍没有这个
|
||||
// extra 键,升级不得把它们静默切进收敛。
|
||||
{"无 extra 默认 off", newTestOAuthAccount(1, nil), codexFingerprintOff},
|
||||
{"空值默认 off", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: ""}), codexFingerprintOff},
|
||||
{"非法值默认 off", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "invalid"}), codexFingerprintOff},
|
||||
{"显式 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},
|
||||
@@ -116,13 +121,24 @@ func TestResolveCodexFingerprintIDsFromRequest_ExplicitOff(t *testing.T) {
|
||||
assert.Nil(t, ids, "显式 off 模式应返回 nil")
|
||||
}
|
||||
|
||||
func TestResolveCodexFingerprintIDsFromRequest_DefaultIsSession(t *testing.T) {
|
||||
// 未显式配置的存量账号不得被收敛(#5610):默认返回 nil,出站身份保持
|
||||
// v0.1.175 之前的客户端原值。
|
||||
func TestResolveCodexFingerprintIDsFromRequest_DefaultIsOff(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)
|
||||
assert.Nil(t, resolveCodexFingerprintIDsFromRequest(account, nil), "无 extra 应视为 off")
|
||||
}
|
||||
|
||||
// 管理员显式 opt-in 的账号行为不变。
|
||||
func TestResolveCodexFingerprintIDsFromRequest_ExplicitOptInHonored(t *testing.T) {
|
||||
for _, mode := range []string{"device", "session", "full"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
account := newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: mode})
|
||||
ids := resolveCodexFingerprintIDsFromRequest(account, nil)
|
||||
require.NotNil(t, ids, "显式配置必须生效")
|
||||
assert.Equal(t, codexFingerprintMode(mode), ids.mode)
|
||||
assert.NotEmpty(t, ids.installationID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- applyCodexFingerprintHeaders: off 模式 ---
|
||||
@@ -458,3 +474,188 @@ func TestExtractClientSessionID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- 透传路径:raw 字节版 client_metadata 改写 ---
|
||||
|
||||
// rawVsMapClientMetadata 用同一份 ids 分别跑 map 版与 raw 字节版,
|
||||
// 返回两侧最终的 client_metadata 解码结果。
|
||||
func rawVsMapClientMetadata(t *testing.T, body []byte, ids *codexFingerprintIDs) (map[string]any, map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &decoded))
|
||||
applyCodexFingerprintClientMetadata(decoded, ids)
|
||||
mapCM, _ := decoded["client_metadata"].(map[string]any)
|
||||
|
||||
rawBody, changed, err := applyCodexFingerprintClientMetadataRaw(body, ids)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
var rawDecoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(rawBody, &rawDecoded))
|
||||
rawCM, _ := rawDecoded["client_metadata"].(map[string]any)
|
||||
return mapCM, rawCM
|
||||
}
|
||||
|
||||
func TestApplyCodexFingerprintClientMetadataRaw_MatchesMapVariant(t *testing.T) {
|
||||
embedded := `{\"installation_id\":\"real-install\",\"session_id\":\"real-session\",\"sandbox\":\"seatbelt\"}`
|
||||
bodies := map[string]string{
|
||||
"no_client_metadata": `{"model":"gpt-5.6-sol","input":[],"stream":true}`,
|
||||
"object_with_extras": `{"model":"gpt-5.6-sol","client_metadata":{"session_id":"client-session","traceparent":"00-abc-def-01","x-codex-turn-metadata":"` + embedded + `"},"stream":true}`,
|
||||
"non_object_value": `{"model":"gpt-5.6-sol","client_metadata":"bogus","stream":true}`,
|
||||
}
|
||||
for _, mode := range []codexFingerprintMode{codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull} {
|
||||
account := newTestOAuthAccount(4242, nil)
|
||||
ids := resolveCodexFingerprintIDs(account, "client-sess-raw", mode)
|
||||
require.NotNil(t, ids)
|
||||
for name, body := range bodies {
|
||||
t.Run(string(mode)+"/"+name, func(t *testing.T) {
|
||||
mapCM, rawCM := rawVsMapClientMetadata(t, []byte(body), ids)
|
||||
assert.Equal(t, mapCM, rawCM, "raw 字节版与 map 版的 client_metadata 结果必须逐点一致")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCodexFingerprintClientMetadataRaw_PreservesUnrelatedFields(t *testing.T) {
|
||||
account := newTestOAuthAccount(4243, nil)
|
||||
ids := resolveCodexFingerprintIDs(account, "client-sess-preserve", codexFingerprintSession)
|
||||
require.NotNil(t, ids)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.6-sol","input":[{"type":"message","role":"user","content":"hi"}],"stream":true,"prompt_cache_key":"pck-1"}`)
|
||||
out, changed, err := applyCodexFingerprintClientMetadataRaw(body, ids)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &decoded))
|
||||
assert.Equal(t, "gpt-5.6-sol", decoded["model"])
|
||||
assert.Equal(t, "pck-1", decoded["prompt_cache_key"])
|
||||
assert.Equal(t, true, decoded["stream"])
|
||||
cm, _ := decoded["client_metadata"].(map[string]any)
|
||||
require.NotNil(t, cm)
|
||||
assert.Equal(t, ids.sessionID, cm["session_id"])
|
||||
assert.Equal(t, ids.turnID, cm["turn_id"])
|
||||
}
|
||||
|
||||
func TestApplyCodexFingerprintClientMetadataRaw_Noop(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.6-sol"}`)
|
||||
out, changed, err := applyCodexFingerprintClientMetadataRaw(body, nil)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, changed)
|
||||
assert.Equal(t, body, out)
|
||||
|
||||
out, changed, err = applyCodexFingerprintClientMetadataRaw(nil, &codexFingerprintIDs{mode: codexFingerprintSession, installationID: "x"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, changed)
|
||||
assert.Nil(t, out)
|
||||
}
|
||||
|
||||
// --- context 暂存与出站头应用(透传/非透传共用 seam)---
|
||||
|
||||
func newFingerprintStageTestContext(t *testing.T) *gin.Context {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestStageCodexFingerprintIDs_NilOverwritesPreviousAccount(t *testing.T) {
|
||||
c := newFingerprintStageTestContext(t)
|
||||
accountA := newTestOAuthAccount(1001, nil)
|
||||
idsA := resolveCodexFingerprintIDs(accountA, "sess-x", codexFingerprintSession)
|
||||
require.NotNil(t, idsA)
|
||||
stageCodexFingerprintIDs(c, idsA)
|
||||
|
||||
// failover 切到 off 模式账号:无条件覆写为 nil,上一账号 IDs 不得残留
|
||||
stageCodexFingerprintIDs(c, nil)
|
||||
|
||||
h := http.Header{}
|
||||
h.Set("session_id", "isolated-session")
|
||||
accountB := newTestOAuthAccount(1002, map[string]any{"codex_fingerprint_mode": "off"})
|
||||
applyStagedCodexFingerprintHeaders(c, accountB, h)
|
||||
assert.Equal(t, "isolated-session", h.Get("session_id"), "off 账号不得应用上一账号的收敛 ID")
|
||||
assert.Empty(t, h.Get("x-codex-installation-id"))
|
||||
}
|
||||
|
||||
func TestApplyStagedCodexFingerprintHeaders_SkipsNonOAuthAccount(t *testing.T) {
|
||||
c := newFingerprintStageTestContext(t)
|
||||
oauthIDs := resolveCodexFingerprintIDs(newTestOAuthAccount(1003, nil), "sess-y", codexFingerprintSession)
|
||||
require.NotNil(t, oauthIDs)
|
||||
stageCodexFingerprintIDs(c, oauthIDs)
|
||||
|
||||
h := http.Header{}
|
||||
apiKeyAccount := &Account{ID: 1004, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
applyStagedCodexFingerprintHeaders(c, apiKeyAccount, h)
|
||||
assert.Empty(t, h.Get("x-codex-installation-id"), "stale 收敛 ID 不得应用到非 OAuth 账号")
|
||||
}
|
||||
|
||||
func TestBuildUpstreamRequestOpenAIPassthrough_AppliesStagedFingerprint(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
// 收敛是显式 opt-in(#5610):显式开启后验证透传路径的出站头收敛。
|
||||
account := newTestOAuthAccount(2001, map[string]any{
|
||||
"openai_oauth_passthrough": true,
|
||||
"codex_fingerprint_mode": "session",
|
||||
})
|
||||
|
||||
c := newFingerprintStageTestContext(t)
|
||||
c.Request.Header.Set("session_id", "real-client-session")
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color")
|
||||
c.Request.Header.Set("originator", "codex_cli_rs")
|
||||
c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"real-install","session_id":"real-session","sandbox":"seatbelt"}`)
|
||||
|
||||
// 复刻 forwardOpenAIPassthrough 的解析+暂存 seam(默认 session 模式)
|
||||
ids := resolveCodexFingerprintIDsFromRequest(account, c.Request.Header)
|
||||
require.NotNil(t, ids)
|
||||
stageCodexFingerprintIDs(c, ids)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.6-sol","input":[],"stream":true}`)
|
||||
req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, ids.sessionID, req.Header.Get("session_id"), "session 模式下出站 session_id 应为账号级收敛值")
|
||||
assert.Equal(t, ids.installationID, req.Header.Get("x-codex-installation-id"))
|
||||
assert.Equal(t, ids.windowID, req.Header.Get("x-codex-window-id"))
|
||||
assert.Equal(t, ids.threadID, req.Header.Get("x-client-request-id"))
|
||||
turnMetadata := req.Header.Get("x-codex-turn-metadata")
|
||||
require.NotEmpty(t, turnMetadata)
|
||||
assert.Contains(t, turnMetadata, ids.sessionID, "turn-metadata JSON 中的 session_id 应被收敛")
|
||||
assert.Contains(t, turnMetadata, `"sandbox":"seatbelt"`, "turn-metadata 未指定字段应原样保留")
|
||||
}
|
||||
|
||||
func TestBuildUpstreamRequestOpenAIPassthrough_OffModeKeepsIsolatedSession(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
account := newTestOAuthAccount(2002, map[string]any{
|
||||
"openai_oauth_passthrough": true,
|
||||
"codex_fingerprint_mode": "off",
|
||||
})
|
||||
|
||||
c := newFingerprintStageTestContext(t)
|
||||
c.Request.Header.Set("session_id", "real-client-session")
|
||||
c.Request.Header.Set("originator", "codex_cli_rs")
|
||||
|
||||
ids := resolveCodexFingerprintIDsFromRequest(account, c.Request.Header)
|
||||
require.Nil(t, ids)
|
||||
stageCodexFingerprintIDs(c, ids)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.6-sol","input":[],"stream":true}`)
|
||||
req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, req.Header.Get("session_id"))
|
||||
assert.NotEqual(t, resolveConvergedSessionID(account), req.Header.Get("session_id"), "off 模式不得收敛 session_id")
|
||||
assert.Empty(t, req.Header.Get("x-codex-window-id"))
|
||||
}
|
||||
|
||||
func TestApplyCodexFingerprintClientMetadataRaw_NonObjectBodyUntouched(t *testing.T) {
|
||||
account := newTestOAuthAccount(4244, nil)
|
||||
ids := resolveCodexFingerprintIDs(account, "client-sess-nonobj", codexFingerprintSession)
|
||||
require.NotNil(t, ids)
|
||||
|
||||
for _, body := range []string{`[1,2,3]`, `"plain string"`, `not json at all`} {
|
||||
out, changed, err := applyCodexFingerprintClientMetadataRaw([]byte(body), ids)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, changed, "非 JSON 对象 body 不应被改写: %s", body)
|
||||
assert.Equal(t, []byte(body), out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,10 +427,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
markDecodedModified()
|
||||
}
|
||||
}
|
||||
// 将 fpIDs 存入 gin context,供 buildUpstreamRequest 中头改写使用
|
||||
if c != nil && fpIDs != nil {
|
||||
c.Set("codex_fingerprint_ids", fpIDs)
|
||||
}
|
||||
// 将 fpIDs 存入 gin context,供 buildUpstreamRequest 中头改写使用。
|
||||
// 无条件覆写(含 nil):failover 从收敛账号切到 off 账号时,上一
|
||||
// 账号的 IDs 不得残留(stageCodexFingerprintIDs 注释)。
|
||||
stageCodexFingerprintIDs(c, fpIDs)
|
||||
}
|
||||
if codexResult.NormalizedModel != "" {
|
||||
upstreamModel = codexResult.NormalizedModel
|
||||
@@ -1094,6 +1094,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
}
|
||||
}
|
||||
}
|
||||
// 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号),
|
||||
// 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。
|
||||
s.guardOpenAICodexTurnStateEcho(c, account, req.Header)
|
||||
if account.Type == AccountTypeOAuth {
|
||||
compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body)
|
||||
// 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。
|
||||
@@ -1144,13 +1147,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
}
|
||||
|
||||
// 指纹收敛:使用 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
applyStagedCodexFingerprintHeaders(c, account, req.Header)
|
||||
|
||||
// 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。
|
||||
// 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。
|
||||
@@ -1165,6 +1162,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
// x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
|
||||
// 保证不被覆盖丢失)。
|
||||
applyOpenAICodexBetaFeatures(c, account, req.Header)
|
||||
setOpenAICodexRoutingHintFromBody(req.Header, account, body)
|
||||
logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http", req.Header, body, "not_applicable")
|
||||
|
||||
|
||||
@@ -80,6 +80,28 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
body = normalizedBody
|
||||
}
|
||||
reqStream = gjson.GetBytes(body, "stream").Bool()
|
||||
|
||||
// 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。
|
||||
// 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科
|
||||
// 手术,透传热路径禁全量 Unmarshal),出站头改写由请求构造器读取
|
||||
// context 中的同一份 IDs 完成(turn_id 等随机字段两侧必须一致)。
|
||||
if !isOpenAIResponsesCompactPath(c) {
|
||||
var clientHeaders http.Header
|
||||
if c != nil && c.Request != nil {
|
||||
clientHeaders = c.Request.Header
|
||||
}
|
||||
fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders)
|
||||
if fpIDs != nil {
|
||||
fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs)
|
||||
if fpErr != nil {
|
||||
return nil, fpErr
|
||||
}
|
||||
if fpChanged {
|
||||
body = fpBody
|
||||
}
|
||||
}
|
||||
stageCodexFingerprintIDs(c, fpIDs)
|
||||
}
|
||||
}
|
||||
|
||||
sanitizedBody, sanitized, err := sanitizeEmptyBase64InputImagesInOpenAIBody(body)
|
||||
@@ -239,6 +261,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
|
||||
serviceTier := extractOpenAIServiceTierFromBody(body)
|
||||
|
||||
// x-codex-turn-state 溯源:下游回传由 writeOpenAIPassthroughResponseHeaders
|
||||
// 在各 handler 的写头点强制放行,铸造账号在此统一记录,供出站守卫剥离
|
||||
// failover 换号后的跨账号回带(openai_codex_turn_state.go)。
|
||||
if extractOpenAICodexTurnState(resp.Header) != "" {
|
||||
s.noteOpenAICodexTurnStateProvenance(c, account)
|
||||
}
|
||||
|
||||
var usage *OpenAIUsage
|
||||
var firstTokenMs *int
|
||||
responseID := ""
|
||||
@@ -376,6 +405,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号),
|
||||
// 剥离后再出站(openai_codex_turn_state.go)。
|
||||
s.guardOpenAICodexTurnStateEcho(c, account, req.Header)
|
||||
|
||||
// 覆盖入站鉴权残留,并注入上游认证
|
||||
req.Header.Del("authorization")
|
||||
req.Header.Del("x-api-key")
|
||||
@@ -447,6 +480,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
||||
req.Header.Set("user-agent", codexCLIUserAgent)
|
||||
}
|
||||
// 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头,
|
||||
// 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置:
|
||||
// 会话隔离之后、终态身份收口之前)。
|
||||
applyStagedCodexFingerprintHeaders(c, account, req.Header)
|
||||
// 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份
|
||||
// (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。
|
||||
if account.Type == AccountTypeOAuth {
|
||||
@@ -459,6 +496,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
// x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
|
||||
// 保证不被覆盖丢失)。
|
||||
applyOpenAICodexBetaFeatures(c, account, req.Header)
|
||||
setOpenAICodexRoutingHintFromBody(req.Header, account, body)
|
||||
logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http_passthrough", req.Header, body, "not_applicable")
|
||||
|
||||
@@ -1650,4 +1690,13 @@ func writeOpenAIPassthroughResponseHeaders(dst http.Header, src http.Header, fil
|
||||
dst.Add(key, v)
|
||||
}
|
||||
}
|
||||
|
||||
// x-codex-turn-state:Codex 回合状态头,客户端会在同回合后续请求回带。
|
||||
// 与上面的用量头不同,这里在上游缺失时也主动清除——failover 换号后残留
|
||||
// 上一账号的 blob 会构成跨账号矛盾(openai_codex_turn_state.go)。
|
||||
turnStateKey := http.CanonicalHeaderKey(openAICodexTurnStateHeader)
|
||||
dst.Del(turnStateKey)
|
||||
for _, v := range getCaseInsensitiveValues(src, openAICodexTurnStateHeader) {
|
||||
dst.Add(turnStateKey, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1535,7 +1535,7 @@ const codexCLIOnlyEnabled = ref(false)
|
||||
const codexCLIOnlyAppServerEnabled = ref(false)
|
||||
type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full'
|
||||
const enableCodexFingerprintMode = ref(false)
|
||||
const codexFingerprintMode = ref<CodexFingerprintMode>('session')
|
||||
const codexFingerprintMode = ref<CodexFingerprintMode>('off')
|
||||
const codexFingerprintModeOptions = computed(() => [
|
||||
{ value: 'off' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintOff') },
|
||||
{ value: 'device' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintDevice') },
|
||||
@@ -1829,7 +1829,8 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
|
||||
if (enableCodexFingerprintMode.value) {
|
||||
const extra = ensureExtra()
|
||||
if (codexFingerprintMode.value !== 'session') {
|
||||
// off = 默认值,清键即可;device/session/full 是显式 opt-in,必须落键(#5610)。
|
||||
if (codexFingerprintMode.value !== 'off') {
|
||||
extra.codex_fingerprint_mode = codexFingerprintMode.value
|
||||
} else {
|
||||
delete extra.codex_fingerprint_mode
|
||||
@@ -2082,7 +2083,7 @@ watch(
|
||||
enableCodexCLIOnly.value = false
|
||||
enableCodexCLIOnlyAppServer.value = false
|
||||
enableCodexFingerprintMode.value = false
|
||||
codexFingerprintMode.value = 'session'
|
||||
codexFingerprintMode.value = 'off'
|
||||
enableOpenAICompactMode.value = false
|
||||
enableOpenAICompactModelMapping.value = false
|
||||
enableRpmLimit.value = false
|
||||
|
||||
@@ -3855,7 +3855,7 @@ const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OF
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
const codexCLIOnlyAppServerEnabled = ref(false)
|
||||
type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full'
|
||||
const codexFingerprintMode = ref<CodexFingerprintMode>('session')
|
||||
const codexFingerprintMode = ref<CodexFingerprintMode>('off')
|
||||
const codexFingerprintModeOptions = computed(() => [
|
||||
{ value: 'off' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintOff') },
|
||||
{ value: 'device' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintDevice') },
|
||||
@@ -4741,7 +4741,7 @@ const resetForm = () => {
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
codexCLIOnlyAppServerEnabled.value = false
|
||||
codexFingerprintMode.value = 'session'
|
||||
codexFingerprintMode.value = 'off'
|
||||
anthropicPassthroughEnabled.value = false
|
||||
anthropicAPIKeyAuthScheme.value = 'x_api_key'
|
||||
webSearchEmulationMode.value = 'default'
|
||||
@@ -4840,7 +4840,9 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
|
||||
} else {
|
||||
delete extra.codex_cli_only_allow_app_server
|
||||
}
|
||||
if (codexFingerprintMode.value !== 'session') {
|
||||
// 收敛是显式 opt-in:off 即默认值,不落键;device/session/full 必须显式写入,
|
||||
// 否则管理员的选择会被当成默认而丢失(#5610)。
|
||||
if (codexFingerprintMode.value !== 'off') {
|
||||
extra.codex_fingerprint_mode = codexFingerprintMode.value
|
||||
} else {
|
||||
delete extra.codex_fingerprint_mode
|
||||
|
||||
@@ -2980,7 +2980,7 @@ const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OF
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
const codexCLIOnlyAppServerEnabled = ref(false)
|
||||
type CodexFingerprintMode = 'off' | 'device' | 'session' | 'full'
|
||||
const codexFingerprintMode = ref<CodexFingerprintMode>('session')
|
||||
const codexFingerprintMode = ref<CodexFingerprintMode>('off')
|
||||
type CodexImageToolMode = 'inherit' | 'enabled' | 'disabled' | 'block'
|
||||
const codexImageToolMode = ref<CodexImageToolMode>('inherit')
|
||||
type AnthropicAPIKeyAuthScheme = 'x_api_key' | 'authorization_bearer'
|
||||
@@ -3440,7 +3440,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
codexCLIOnlyAppServerEnabled.value = false
|
||||
codexFingerprintMode.value = 'session'
|
||||
codexFingerprintMode.value = 'off'
|
||||
codexImageToolMode.value = 'inherit'
|
||||
anthropicPassthroughEnabled.value = false
|
||||
anthropicAPIKeyAuthScheme.value = 'x_api_key'
|
||||
@@ -3494,9 +3494,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
}
|
||||
if (newAccount.type === 'oauth') {
|
||||
const fpMode = extra?.codex_fingerprint_mode as string | undefined
|
||||
// 缺省/非法值按 off 呈现,与后端 GetCodexFingerprintMode 的 opt-in 语义一致(#5610)
|
||||
codexFingerprintMode.value = (['off', 'device', 'session', 'full'].includes(fpMode || '')
|
||||
? fpMode as CodexFingerprintMode
|
||||
: 'session')
|
||||
: 'off')
|
||||
}
|
||||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||
const compactMappings = credentials?.compact_model_mapping as Record<string, string> | undefined
|
||||
@@ -4843,9 +4844,10 @@ const handleSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 指纹收敛模式:默认 session,不写入;非默认值显式写入(包括 off)
|
||||
// 指纹收敛模式:默认 off(不写入);device/session/full 是显式 opt-in,
|
||||
// 必须落键,否则管理员的选择会被后端当作"未设置"而回落到 off(#5610)。
|
||||
if (props.account.type === 'oauth') {
|
||||
if (codexFingerprintMode.value !== 'session') {
|
||||
if (codexFingerprintMode.value !== 'off') {
|
||||
newExtra.codex_fingerprint_mode = codexFingerprintMode.value
|
||||
} else {
|
||||
delete newExtra.codex_fingerprint_mode
|
||||
|
||||
@@ -574,10 +574,10 @@ export default {
|
||||
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)',
|
||||
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 by default (client identifiers pass through as-is); opt in explicitly when needed. Some accounts reported quota shrinkage after enabling convergence, so choose based on your own measurements.',
|
||||
codexFingerprintOff: 'Off (passthrough, default)',
|
||||
codexFingerprintDevice: 'Device only',
|
||||
codexFingerprintSession: 'Device + Session (recommended)',
|
||||
codexFingerprintSession: 'Device + Session',
|
||||
codexFingerprintFull: 'Full convergence',
|
||||
codexImageTool: 'Codex image bridge policy',
|
||||
codexImageToolDesc:
|
||||
|
||||
@@ -644,10 +644,10 @@ export default {
|
||||
codexCLIOnlyAppServer: '允许 Codex app-server 客户端',
|
||||
codexCLIOnlyAppServerDesc: '仅在上方开关开启时生效。开启后本账号额外放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件),仍需通过全局引擎指纹门;与全局 app-server 开关取 OR(任一开即放行)。',
|
||||
codexFingerprintMode: 'Codex 指纹收敛',
|
||||
codexFingerprintModeDesc: '多人共享同一 OAuth 账号时,将各用户的设备/会话标识收敛为账号级恒定值,减少上游可见的设备数和会话数。关闭时原样透传客户端标识。',
|
||||
codexFingerprintOff: '关闭(透传)',
|
||||
codexFingerprintModeDesc: '多人共享同一 OAuth 账号时,将各用户的设备/会话标识收敛为账号级恒定值,减少上游可见的设备数和会话数。默认关闭(原样透传客户端标识),需要时再显式开启;部分账号开启收敛后出现过额度缩水,请按自己的实测结果选择。',
|
||||
codexFingerprintOff: '关闭(透传,默认)',
|
||||
codexFingerprintDevice: '仅设备',
|
||||
codexFingerprintSession: '设备+会话(推荐)',
|
||||
codexFingerprintSession: '设备+会话',
|
||||
codexFingerprintFull: '完全收敛',
|
||||
codexImageTool: 'Codex 图片桥接策略',
|
||||
codexImageToolDesc:
|
||||
|
||||
Reference in New Issue
Block a user