feat: add explicit account fingerprint option

This commit is contained in:
bmiddle1.backend.zhangfucheng committed 2026-08-22 13:16:21 +08:00
1 parent 5aa4a065f3
commit 6d3b5c0b1b
11 files changed
+47 -19

No files matched your search

+6 -2
View File
@@ -101,6 +101,10 @@ GATEWAY_OPENAI_ACCOUNT_UNIQUE_FINGERPRINT_ENABLED=true
它会按账号固定 Codex 设备指纹;单个账号可在 `extra` 中设置
`codex_fingerprint_mode: off` 关闭。该开关只影响 OpenAI OAuth/Codex 账号,不影响 API Key。
账号编辑页的“Codex 指纹收敛”下拉框新增“账号唯一设备(新增,推荐)”选项。选择后会写入
`codex_fingerprint_mode: account_device`,只固定该账号的设备指纹,不收敛会话和线程;
不选择账号级覆盖时,仍由全局开关提供相同的默认行为。
### 3. 创建数据目录
```bash
@@ -399,13 +403,13 @@ docker compose \
如果服务器目录是 `/opt/sub2api`,只需替换第一条路径。升级完成后重新执行“判断功能是否生效”中的镜像、环境变量和日志检查。
更新后可在管理后台点击刷新,版本应显示为类似 `v0.1.179-overdraft.2`,更新方式应提示源码构建使用 `git pull`。也可以直接检查容器内二进制版本:
更新后可在管理后台点击刷新,版本应显示为类似 `v0.1.179-overdraft.3`,更新方式应提示源码构建使用 `git pull`。也可以直接检查容器内二进制版本:
```bash
docker exec sub2api /app/sub2api -version
```
维护者发布新的源码版本时必须递增 `FORK_VERSION`,例如从 `0.1.177-overdraft.3` 改为 `0.1.177-overdraft.4`。同步到新的上游 Sub2API 版本时,使用 `0.1.179-overdraft.2` 这样的版本号。
维护者发布新的源码版本时必须递增 `FORK_VERSION`,例如从 `0.1.177-overdraft.3` 改为 `0.1.177-overdraft.4`。同步到新的上游 Sub2API 版本时,使用 `0.1.179-overdraft.3` 这样的版本号。
## 合并 Sub2API 官方更新
+2 -1
View File
@@ -11,6 +11,7 @@
- 代码默认值:关闭;`deploy/config.example.yaml` 部署示例默认开启
- OpenAI 账号固定指纹开关:`gateway.openai_account_unique_fingerprint_enabled`
- 固定指纹开关默认开启;没有显式账号模式时按账号 ID 派生稳定设备指纹,账号 extra 中显式设置 `codex_fingerprint_mode: off` 可单独关闭
- 账号编辑页新增 `账号唯一设备(新增,推荐)` 选项,对应 `codex_fingerprint_mode: account_device`;它只固定该账号的设备指纹,不改变会话/线程收敛
- Fork 版本文件:`FORK_VERSION`
- 更新源:`DeanZFC/sub2api-overdraft` 的 `codex-overdraft` 分支
@@ -18,7 +19,7 @@
源码 Docker 构建会读取根目录的 `FORK_VERSION`,并以 `BuildType=source` 写入二进制。后台更新服务通过 GitHub Contents API 读取 Fork 分支上的同名文件,使用语义化版本比较判断是否有新版本。Redis 缓存同时记录仓库和构建类型,因此旧的官方更新缓存不会继续生效。
源码构建仅显示 `git pull` 更新提示,`PerformUpdate`、指定版本回退和在线回退列表均被禁用,防止官方二进制覆盖透支功能。维护者每次发布 Fork 更新都必须递增 `FORK_VERSION`;当前版本为 `0.1.179-overdraft.2`。
源码构建仅显示 `git pull` 更新提示,`PerformUpdate`、指定版本回退和在线回退列表均被禁用,防止官方二进制覆盖透支功能。维护者每次发布 Fork 更新都必须递增 `FORK_VERSION`;当前版本为 `0.1.179-overdraft.3`。
Sub2API `v0.1.179` 继续保留原生 remote compaction v2 在 `/responses` 路径。本定制同时检查旧 Compact 路径和原生 v2 请求信号,二者都不会开启额度透支调度或注入透支请求形态。
+1 -1
View File
@@ -1 +1 @@
0.1.179-overdraft.2
0.1.179-overdraft.3
@@ -88,6 +88,10 @@ const (
// 账号 extra 未显式配置模式时,GetCodexFingerprintMode 返回此值;
// 出站请求是否按全局开关提升到 device 模式由 resolveCodexFingerprintMode 决定。
codexFingerprintOff codexFingerprintMode = "off"
// codexFingerprintAccountDevice 使用账号 ID(已有系统种子优先)派生
// 唯一且稳定的设备指纹,仅收敛 installation_id。这是全局默认行为的
// 显式账号级选项,不要求把系统种子写入账号 extra。
codexFingerprintAccountDevice codexFingerprintMode = "account_device"
// codexFingerprintDevice 仅收敛 installation_id 为账号级恒定值。
// 上游看到 1 台设备 + 多会话(每用户各自的 session)。
codexFingerprintDevice codexFingerprintMode = "device"
@@ -137,7 +141,7 @@ func codexFingerprintModeFromExtra(extra map[string]any) codexFingerprintMode {
}
raw, _ := extra[codexFingerprintModeExtraKey].(string)
switch codexFingerprintMode(strings.TrimSpace(raw)) {
case codexFingerprintOff, codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull:
case codexFingerprintOff, codexFingerprintAccountDevice, codexFingerprintDevice, codexFingerprintSession, codexFingerprintFull:
return codexFingerprintMode(strings.TrimSpace(raw))
default:
return codexFingerprintOff
@@ -239,7 +243,8 @@ func resolveCodexFingerprintMode(account *Account, enabled bool) (codexFingerpri
}
if account.Extra != nil {
if _, configured := account.Extra[codexFingerprintModeExtraKey]; configured {
return codexFingerprintModeFromExtra(account.Extra), false
mode := codexFingerprintModeFromExtra(account.Extra)
return mode, mode == codexFingerprintAccountDevice
}
}
if enabled {
@@ -361,7 +366,7 @@ func resolveCodexFingerprintIDsWithSeed(account *Account, clientSessionID string
}
switch mode {
case codexFingerprintDevice:
case codexFingerprintAccountDevice, codexFingerprintDevice:
return ids
case codexFingerprintSession:
@@ -433,7 +438,7 @@ func applyCodexFingerprintHeaders(h http.Header, ids *codexFingerprintIDs) {
// 所有非 off 模式都收敛 installation_id
h.Set("x-codex-installation-id", ids.installationID)
if ids.mode == codexFingerprintDevice {
if ids.mode == codexFingerprintAccountDevice || ids.mode == codexFingerprintDevice {
rewriteCodexTurnMetadataFields(h, map[string]any{
"installation_id": ids.installationID,
})
@@ -519,7 +524,7 @@ func applyCodexFingerprintToClientMetadataMap(existing map[string]any, ids *code
modified = true
}
if ids.mode == codexFingerprintDevice {
if ids.mode == codexFingerprintAccountDevice || ids.mode == codexFingerprintDevice {
rewriteClientMetadataEmbeddedTurnMetadata(existing, map[string]any{
"installation_id": ids.installationID,
})
@@ -70,6 +70,7 @@ func TestGetCodexFingerprintMode(t *testing.T) {
{"空值默认 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},
{"账号唯一设备", newTestOAuthAccount(1, map[string]any{codexFingerprintModeExtraKey: "account_device"}), codexFingerprintAccountDevice},
{"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},
@@ -81,6 +82,18 @@ func TestGetCodexFingerprintMode(t *testing.T) {
}
}
func TestResolveCodexFingerprintIDsFromRequest_AccountDeviceModeUsesStableAccountSeed(t *testing.T) {
account := newTestOAuthAccount(904, map[string]any{codexFingerprintModeExtraKey: "account_device"})
first := resolveCodexFingerprintIDsFromRequest(account, nil)
second := resolveCodexFingerprintIDsFromRequest(account, nil)
require.NotNil(t, first)
require.NotNil(t, second)
assert.Equal(t, codexFingerprintAccountDevice, first.mode)
assert.Equal(t, first.installationID, second.installationID)
assert.Equal(t, resolveConvergedInstallationID(account, deriveAccountCodexFingerprintSeed(account)), first.installationID)
assert.Empty(t, account.Extra[codexFingerprintSeedExtraKey], "账号唯一设备模式不需要写入系统种子")
}
// --- resolveConvergedInstallationID ---
func TestResolveConvergedInstallationID_UsesDeviceID(t *testing.T) {
+1 -1
View File
@@ -2040,7 +2040,7 @@ func normalizeOpenAIWSHandshakeCompatibility(account *Account, headers http.Head
} else {
key.codexInstallationID = normalizeOpenAIWSStableIdentityHeader(headers, "x-codex-installation-id")
}
if mode == codexFingerprintDevice {
if mode == codexFingerprintAccountDevice || mode == codexFingerprintDevice {
return key
}
key.sessionIDHyphen = normalizeOpenAIWSStableIdentityHeader(headers, "session-id")
@@ -1705,11 +1705,12 @@ 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'
type CodexFingerprintMode = 'off' | 'account_device' | 'device' | 'session' | 'full'
const enableCodexFingerprintMode = ref(false)
const codexFingerprintMode = ref<CodexFingerprintMode>('off')
const codexFingerprintModeOptions = computed(() => [
{ value: 'off' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintOff') },
{ value: 'account_device' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintAccountDevice') },
{ 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') },
@@ -2089,7 +2090,7 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
if (enableCodexFingerprintMode.value) {
const extra = ensureExtra()
// off = 默认值,清键即可;device/session/full 是显式 opt-in,必须落键(#5610)。
// off = 默认值,清键即可;account_device/device/session/full 是显式 opt-in,必须落键。
if (codexFingerprintMode.value !== 'off') {
extra.codex_fingerprint_mode = codexFingerprintMode.value
} else {
@@ -4171,10 +4171,11 @@ 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'
type CodexFingerprintMode = 'off' | 'account_device' | 'device' | 'session' | 'full'
const codexFingerprintMode = ref<CodexFingerprintMode>('off')
const codexFingerprintModeOptions = computed(() => [
{ value: 'off' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintOff') },
{ value: 'account_device' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintAccountDevice') },
{ 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') },
@@ -5164,7 +5165,7 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
} else {
delete extra.codex_cli_only_allow_app_server
}
// 收敛是显式 opt-in:off 即默认值,不落键;device/session/full 必须显式写入,
// 收敛是显式 opt-in:off 即默认值,不落键;account_device/device/session/full 必须显式写入,
// 否则管理员的选择会被当成默认而丢失(#5610)。
if (codexFingerprintMode.value !== 'off') {
extra.codex_fingerprint_mode = codexFingerprintMode.value
@@ -3168,7 +3168,7 @@ 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'
type CodexFingerprintMode = 'off' | 'account_device' | 'device' | 'session' | 'full'
const codexFingerprintMode = ref<CodexFingerprintMode>('off')
type CodexImageToolMode = 'inherit' | 'enabled' | 'disabled' | 'block'
const codexImageToolMode = ref<CodexImageToolMode>('inherit')
@@ -3203,6 +3203,7 @@ 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: 'account_device' as CodexFingerprintMode, label: t('admin.accounts.openai.codexFingerprintAccountDevice') },
{ 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') },
@@ -3698,7 +3699,7 @@ 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 || '')
codexFingerprintMode.value = (['off', 'account_device', 'device', 'session', 'full'].includes(fpMode || '')
? fpMode as CodexFingerprintMode
: 'off')
}
@@ -5117,7 +5118,7 @@ const handleSubmit = async () => {
}
}
// 指纹收敛模式:默认 off(不写入);device/session/full 是显式 opt-in,
// 指纹收敛模式:默认 off(不写入);account_device/device/session/full 是显式 opt-in,
// 必须落键,否则管理员的选择会被后端当作"未设置"而回落到 off(#5610)。
if (props.account.type === 'oauth') {
if (codexFingerprintMode.value !== 'off') {
@@ -612,8 +612,9 @@ 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 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.',
codexFingerprintModeDesc: 'When multiple users share the same OAuth account, converge device/session identifiers to account-level stable values. The global account-unique device fingerprint is enabled by default; use this field for an explicit per-account override.',
codexFingerprintOff: 'Off (passthrough, default)',
codexFingerprintAccountDevice: 'Account-unique device (new, recommended)',
codexFingerprintDevice: 'Device only',
codexFingerprintSession: 'Device + Session',
codexFingerprintFull: 'Full convergence',
@@ -682,8 +682,9 @@ export default {
codexCLIOnlyAppServer: '允许 Codex app-server 客户端',
codexCLIOnlyAppServerDesc: '仅在上方开关开启时生效。开启后本账号额外放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件),仍需通过全局引擎指纹门;与全局 app-server 开关取 OR(任一开即放行)。',
codexFingerprintMode: 'Codex 指纹收敛',
codexFingerprintModeDesc: '多人共享同一 OAuth 账号时,将各用户的设备/会话标识收敛为账号级恒定值,减少上游可见的设备数和会话数。默认关闭(原样透传客户端标识),需要时再显式开启;部分账号开启收敛后出现过额度缩水,请按自己的实测结果选择。',
codexFingerprintModeDesc: '多人共享同一 OAuth 账号时,将各用户的设备/会话标识收敛为账号级恒定值,减少上游可见的设备数和会话数。全局默认已启用账号唯一设备指纹;此处可为单个账号显式覆盖。',
codexFingerprintOff: '关闭(透传,默认)',
codexFingerprintAccountDevice: '账号唯一设备(新增,推荐)',
codexFingerprintDevice: '仅设备',
codexFingerprintSession: '设备+会话',
codexFingerprintFull: '完全收敛',