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:
shaw
2026-08-12 17:57:50 +08:00
parent 4ec9ceec4a
commit c0ab3a00ea
8 changed files with 901 additions and 0 deletions
@@ -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 请求;账号级策略优先于渠道和全局配置,不影响独立图片生成接口。',