Merge pull request #2834 from DaydreamCoding/pr/openai-codex-cli-allow-claude-code

feat(openai): codex_cli_only 新增放行 Claude Code Codex 插件的机制
This commit is contained in:
Wesley Liddick
2026-05-29 10:30:33 +08:00
committed by GitHub
23 changed files with 787 additions and 23 deletions
+2
View File
@@ -560,6 +560,7 @@ export interface SystemSettings {
rewrite_message_cache_control: boolean;
antigravity_user_agent_version: string;
openai_codex_user_agent: string;
openai_allow_claude_code_codex_plugin: boolean;
web_search_emulation_enabled?: boolean;
// Payment configuration
@@ -792,6 +793,7 @@ export interface UpdateSettingsRequest {
rewrite_message_cache_control?: boolean;
antigravity_user_agent_version?: string;
openai_codex_user_agent?: string;
openai_allow_claude_code_codex_plugin?: boolean;
// Payment configuration
payment_enabled?: boolean;
risk_control_enabled?: boolean;
@@ -742,6 +742,50 @@
</div>
</div>
<!-- OpenAI OAuth: 额外放行 Claude Code 的 Codex 插件 -->
<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
id="bulk-edit-openai-codex-allow-claude-code-label"
class="input-label mb-0"
for="bulk-edit-openai-codex-allow-claude-code-enabled"
>
{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCode') }}
</label>
<input
v-model="enableCodexCLIOnlyAllowClaudeCode"
id="bulk-edit-openai-codex-allow-claude-code-enabled"
type="checkbox"
aria-controls="bulk-edit-openai-codex-allow-claude-code"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div
id="bulk-edit-openai-codex-allow-claude-code"
:class="!enableCodexCLIOnlyAllowClaudeCode && 'pointer-events-none opacity-50'"
>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }}
</p>
<button
id="bulk-edit-openai-codex-allow-claude-code-toggle"
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
codexCLIOnlyAllowClaudeCodeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
@click="codexCLIOnlyAllowClaudeCodeEnabled = !codexCLIOnlyAllowClaudeCodeEnabled"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
codexCLIOnlyAllowClaudeCodeEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- OpenAI API Key WS mode -->
<div v-if="allOpenAIAPIKey" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -1219,6 +1263,7 @@ const enableOpenAIPassthrough = ref(false)
const enableOpenAIWSMode = ref(false)
const enableOpenAIAPIKeyWSMode = ref(false)
const enableCodexCLIOnly = ref(false)
const enableCodexCLIOnlyAllowClaudeCode = ref(false)
const enableOpenAICompactMode = ref(false)
const enableOpenAICompactModelMapping = ref(false)
const enableRpmLimit = ref(false)
@@ -1246,6 +1291,7 @@ const openaiPassthroughEnabled = ref(false)
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const codexCLIOnlyAllowClaudeCodeEnabled = ref(false)
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAICompactModelMappings = ref<ModelMapping[]>([])
const rpmLimitEnabled = ref(false)
@@ -1496,6 +1542,11 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
extra.codex_cli_only = codexCLIOnlyEnabled.value
}
if (enableCodexCLIOnlyAllowClaudeCode.value) {
const extra = ensureExtra()
extra.codex_cli_only_allowed_clients = codexCLIOnlyAllowClaudeCodeEnabled.value ? ['claude_code'] : []
}
if (enableOpenAICompactMode.value) {
const extra = ensureExtra()
extra.openai_compact_mode = openAICompactMode.value
@@ -1602,6 +1653,7 @@ const handleSubmit = async () => {
enableOpenAIWSMode.value ||
enableOpenAIAPIKeyWSMode.value ||
enableCodexCLIOnly.value ||
enableCodexCLIOnlyAllowClaudeCode.value ||
enableOpenAICompactMode.value ||
enableOpenAICompactModelMapping.value ||
enableRpmLimit.value ||
@@ -1704,6 +1756,7 @@ watch(
enableOpenAIWSMode.value = false
enableOpenAIAPIKeyWSMode.value = false
enableCodexCLIOnly.value = false
enableCodexCLIOnlyAllowClaudeCode.value = false
enableOpenAICompactMode.value = false
enableOpenAICompactModelMapping.value = false
enableRpmLimit.value = false
@@ -1727,6 +1780,7 @@ watch(
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
codexCLIOnlyAllowClaudeCodeEnabled.value = false
openAICompactMode.value = 'auto'
openAICompactModelMappings.value = []
rpmLimitEnabled.value = false
@@ -2635,6 +2635,32 @@
/>
</button>
</div>
<div
v-if="codexCLIOnlyEnabled"
class="mt-4 flex items-center justify-between border-l-2 border-gray-200 pl-4 dark:border-dark-600"
>
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCode') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }}
</p>
</div>
<button
type="button"
@click="codexCLIOnlyAllowClaudeCodeEnabled = !codexCLIOnlyAllowClaudeCodeEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
codexCLIOnlyAllowClaudeCodeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
codexCLIOnlyAllowClaudeCodeEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- OpenAI Compact 能力配置 -->
@@ -3383,6 +3409,7 @@ const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_comple
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const codexCLIOnlyAllowClaudeCodeEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const webSearchEmulationMode = ref('default')
const webSearchGlobalEnabled = ref(false)
@@ -3807,6 +3834,7 @@ watch(
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
codexCLIOnlyAllowClaudeCodeEnabled.value = false
}
if (newPlatform !== 'anthropic') {
anthropicPassthroughEnabled.value = false
@@ -3827,6 +3855,7 @@ watch(
([category, platform]) => {
if (platform === 'openai' && category !== 'oauth-based') {
codexCLIOnlyEnabled.value = false
codexCLIOnlyAllowClaudeCodeEnabled.value = false
}
if (platform !== 'anthropic' || category !== 'apikey') {
anthropicPassthroughEnabled.value = false
@@ -4207,6 +4236,7 @@ const resetForm = () => {
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
codexCLIOnlyAllowClaudeCodeEnabled.value = false
anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
// Reset quota control state
@@ -4285,6 +4315,15 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
} else {
delete extra.codex_cli_only
}
if (
accountCategory.value === 'oauth-based' &&
codexCLIOnlyEnabled.value &&
codexCLIOnlyAllowClaudeCodeEnabled.value
) {
extra.codex_cli_only_allowed_clients = ['claude_code']
} else {
delete extra.codex_cli_only_allowed_clients
}
if (openAICompactMode.value !== 'auto') {
extra.openai_compact_mode = openAICompactMode.value
} else {
@@ -1673,6 +1673,32 @@
/>
</button>
</div>
<div
v-if="codexCLIOnlyEnabled"
class="mt-4 flex items-center justify-between border-l-2 border-gray-200 pl-4 dark:border-dark-600"
>
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCode') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }}
</p>
</div>
<button
type="button"
@click="codexCLIOnlyAllowClaudeCodeEnabled = !codexCLIOnlyAllowClaudeCodeEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
codexCLIOnlyAllowClaudeCodeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
codexCLIOnlyAllowClaudeCodeEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<div
@@ -2476,6 +2502,7 @@ const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_comple
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const codexCLIOnlyAllowClaudeCodeEnabled = ref(false)
type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled'
const codexImageGenerationBridgeMode = ref<CodexImageGenerationBridgeMode>('inherit')
const anthropicPassthroughEnabled = ref(false)
@@ -2848,6 +2875,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
codexCLIOnlyAllowClaudeCodeEnabled.value = false
codexImageGenerationBridgeMode.value = 'inherit'
anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
@@ -2885,6 +2913,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
})
if (newAccount.type === 'oauth') {
codexCLIOnlyEnabled.value = extra?.codex_cli_only === true
codexCLIOnlyAllowClaudeCodeEnabled.value =
Array.isArray(extra?.codex_cli_only_allowed_clients) &&
(extra.codex_cli_only_allowed_clients as unknown[]).includes('claude_code')
}
const credentials = newAccount.credentials as Record<string, unknown> | undefined
const compactMappings = credentials?.compact_model_mapping as Record<string, string> | undefined
@@ -4004,6 +4035,12 @@ const handleSubmit = async () => {
} else {
delete newExtra.codex_cli_only
}
// 仅当 codex_cli_only 开启且子开关开启时写入 Claude Code 插件白名单,否则清除避免孤立字段
if (codexCLIOnlyEnabled.value && codexCLIOnlyAllowClaudeCodeEnabled.value) {
newExtra.codex_cli_only_allowed_clients = ['claude_code']
} else {
delete newExtra.codex_cli_only_allowed_clients
}
}
updatePayload.extra = newExtra
@@ -197,6 +197,25 @@ describe('BulkEditAccountModal', () => {
})
})
it('OpenAI OAuth 批量编辑应提交 codex_cli_only_allowed_clients 字段', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
selectedTypes: ['oauth']
})
await wrapper.get('#bulk-edit-openai-codex-allow-claude-code-enabled').setValue(true)
await wrapper.get('#bulk-edit-openai-codex-allow-claude-code-toggle').trigger('click')
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
await flushPromises()
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
extra: {
codex_cli_only_allowed_clients: ['claude_code']
}
})
})
it('OpenAI API Key 批量编辑应提交 API Key 专属 WS mode 字段', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
+6
View File
@@ -3372,6 +3372,9 @@ export default {
codexCLIOnly: 'Codex official clients only',
codexCLIOnlyDesc:
'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.',
codexCLIOnlyAllowClaudeCode: "Also allow Claude Code's Codex plugin",
codexCLIOnlyAllowClaudeCodeDesc:
'Only takes effect when the switch above is on. Additionally allows requests from the Claude Code Codex plugin (exact match on originator=Claude Code) without weakening blocking of other non-official clients.',
codexImageGenerationBridge: 'Codex image-generation bridge',
codexImageGenerationBridgeDesc:
'Account policy takes precedence over channel and global settings. Only controls whether Codex requests through the /responses text endpoint receive the image_generation tool; standalone image-generation endpoints are unaffected.',
@@ -5611,6 +5614,9 @@ export default {
openaiCodexUserAgent: 'OpenAI Codex UA',
openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)',
openaiCodexUserAgentHint: 'Used to bypass Cloudflare browser-UA challenges on the OpenAI upstream. Only applies when the client User-Agent is detected as a browser (Mozilla/...). Leave empty to use the built-in default.',
openaiAllowClaudeCodeCodexPlugin: "Allow using the Codex plugin in Claude Code",
openaiAllowClaudeCodeCodexPluginDesc:
"Global switch; only affects OpenAI OAuth accounts that have 'Codex official clients only' enabled. When on, all such accounts additionally allow requests from the Claude Code Codex plugin (exact match on originator=Claude Code) without per-account config; upstream requests remain pass-through.",
},
webSearchEmulation: {
title: 'Web Search Emulation',
+5
View File
@@ -3516,6 +3516,8 @@ export default {
responsesStatusForcedChatCompletions: '已强制 Chat Completions',
codexCLIOnly: '仅允许 Codex 官方客户端',
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
codexCLIOnlyAllowClaudeCode: '额外放行 Claude Code 的 Codex 插件',
codexCLIOnlyAllowClaudeCodeDesc: '仅在上方开关开启时生效。额外放行通过 Claude Code 的 Codex 插件发起的请求(精确匹配 originator=Claude Code),不影响对其他非官方客户端的拦截。',
codexImageGenerationBridge: 'Codex 图片生成桥接',
codexImageGenerationBridgeDesc:
'账号级策略优先于渠道和全局配置。仅控制 Codex 走 /responses 文本端点时是否注入 image_generation 工具;不影响独立图片生成接口。',
@@ -5766,6 +5768,9 @@ export default {
openaiCodexUserAgent: 'OpenAI Codex UA',
openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)',
openaiCodexUserAgentHint: '用于规避 OpenAI 上游 Cloudflare 对浏览器 UA 的访问质询。仅在检测到客户端 User-Agent 为浏览器(Mozilla/...)时生效,其他客户端原样透传。留空使用内置默认值。',
openaiAllowClaudeCodeCodexPlugin: '允许在 Claude Code 中使用 Codex 插件',
openaiAllowClaudeCodeCodexPluginDesc:
'全局开关,仅对已开启「仅允许 Codex 官方客户端」的 OpenAI OAuth 账号生效。开启后,所有此类账号都额外放行通过 Claude Code 的 Codex 插件发起的请求(精确匹配 originator=Claude Code),无需逐账号配置;上游请求仍保持透传。',
},
webSearchEmulation: {
title: 'Web Search 模拟',
+15
View File
@@ -3948,6 +3948,19 @@
}}
</p>
</div>
<!-- 是否允许在 Claude Code 中使用 Codex 插件(全局开关) -->
<div class="flex items-center justify-between">
<div class="pr-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.settings.gatewayForwarding.openaiAllowClaudeCodeCodexPlugin") }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.openaiAllowClaudeCodeCodexPluginDesc") }}
</p>
</div>
<Toggle v-model="form.openai_allow_claude_code_codex_plugin" />
</div>
</div>
</div>
<!-- Web Search Emulation -->
@@ -7162,6 +7175,7 @@ const form = reactive<SettingsForm>({
rewrite_message_cache_control: false,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
openai_allow_claude_code_codex_plugin: false,
// 余额、订阅到期与账号限额通知
balance_low_notify_enabled: false,
balance_low_notify_threshold: 0,
@@ -8267,6 +8281,7 @@ async function saveSettings() {
form.antigravity_user_agent_version?.trim() || "",
openai_codex_user_agent:
form.openai_codex_user_agent?.trim() || "",
openai_allow_claude_code_codex_plugin: form.openai_allow_claude_code_codex_plugin,
// Payment configuration
payment_enabled: form.payment_enabled,
risk_control_enabled: form.risk_control_enabled,