Merge pull request #4981 from INKCR0W/fix/openai-preserve-codex-namespaces

fix(openai): OAuth 原生 Responses 默认保留 Codex namespace,修复 code_mode_only 模型无法派发子代理
This commit is contained in:
Wesley Liddick
2026-07-31 21:49:44 +08:00
committed by GitHub
14 changed files with 743 additions and 26 deletions
@@ -82,6 +82,57 @@
</div>
</div>
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
<div
v-if="allOpenAIOAuthOnly"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="mb-3 flex items-center justify-between">
<div class="flex-1 pr-4">
<label
id="bulk-edit-openai-flatten-namespaces-label"
class="input-label mb-0"
for="bulk-edit-openai-flatten-namespaces-enabled"
>
{{ t('admin.accounts.openai.flattenNamespaces') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
</p>
</div>
<input
v-model="enableOpenAIFlattenNamespaces"
id="bulk-edit-openai-flatten-namespaces-enabled"
type="checkbox"
aria-controls="bulk-edit-openai-flatten-namespaces-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div
id="bulk-edit-openai-flatten-namespaces-body"
:class="!enableOpenAIFlattenNamespaces && 'pointer-events-none opacity-50'"
role="group"
aria-labelledby="bulk-edit-openai-flatten-namespaces-label"
>
<button
id="bulk-edit-openai-flatten-namespaces-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',
openaiFlattenNamespacesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
>
<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',
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- Base URL (API Key only) -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -1331,6 +1382,16 @@ const allOpenAIOAuth = computed(() => {
)
})
// 严格 OAuth(不含 setup-token):namespace 摊平兼容开关只对 OAuth 账号生效
const allOpenAIOAuthOnly = computed(() => {
return (
targetSelectedPlatforms.value.length === 1 &&
targetSelectedPlatforms.value[0] === 'openai' &&
targetSelectedTypes.value.length > 0 &&
targetSelectedTypes.value.every(t => t === 'oauth')
)
})
const allOpenAIAPIKey = computed(() => {
return (
targetSelectedPlatforms.value.length === 1 &&
@@ -1398,6 +1459,7 @@ const enableRateMultiplier = ref(false)
const enableStatus = ref(false)
const enableGroups = ref(false)
const enableOpenAIPassthrough = ref(false)
const enableOpenAIFlattenNamespaces = ref(false)
const enableOpenAIWSMode = ref(false)
const enableOpenAIAPIKeyWSMode = ref(false)
const enableUpstreamBillingAutoProbe = ref(false)
@@ -1429,6 +1491,8 @@ const rateMultiplier = ref(1)
const status = ref<'active' | 'inactive'>('active')
const groupIds = ref<number[]>([])
const openaiPassthroughEnabled = ref(false)
// Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
const openaiFlattenNamespacesEnabled = ref(false)
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled')
@@ -1638,6 +1702,12 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
}
}
// 同时校验可见性:勾选后又改了目标筛选条件时,不应把该键写到非 OAuth 账号上
if (enableOpenAIFlattenNamespaces.value && allOpenAIOAuthOnly.value) {
const extra = ensureExtra()
extra.openai_responses_flatten_namespaces = openaiFlattenNamespacesEnabled.value
}
if (enableModelRestriction.value && !isOpenAIModelRestrictionDisabled.value) {
// 统一使用 model_mapping 字段
if (modelRestrictionMode.value === 'whitelist') {
@@ -1806,6 +1876,7 @@ const handleSubmit = async () => {
const hasAnyFieldEnabled =
enableBaseUrl.value ||
enableOpenAIPassthrough.value ||
enableOpenAIFlattenNamespaces.value ||
enableModelRestriction.value ||
enableCustomErrorCodes.value ||
enableInterceptWarmup.value ||
@@ -1946,6 +2017,7 @@ watch(
enableStatus.value = false
enableGroups.value = false
enableOpenAIPassthrough.value = false
enableOpenAIFlattenNamespaces.value = false
enableOpenAIWSMode.value = false
enableOpenAIAPIKeyWSMode.value = false
enableUpstreamBillingAutoProbe.value = false
@@ -1958,6 +2030,7 @@ watch(
// Reset all values
baseUrl.value = ''
openaiPassthroughEnabled.value = false
openaiFlattenNamespacesEnabled.value = false
modelRestrictionMode.value = 'whitelist'
allowedModels.value = []
modelMappings.value = []
@@ -2781,6 +2781,37 @@
</div>
</div>
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
<div
v-if="form.platform === 'openai' && form.type === 'oauth'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.flattenNamespaces') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
</p>
</div>
<button
type="button"
data-testid="create-openai-flatten-namespaces-toggle"
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
: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',
openaiFlattenNamespacesEnabled ? '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',
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- OpenAI WS Mode 三态(off/ctx_pool/passthrough) -->
<div
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
@@ -3778,6 +3809,8 @@ const applyGrokOAuthUpstreamConfig = (credentials: Record<string, unknown>) => {
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false)
// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
const openaiFlattenNamespacesEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
const openAILongContextBillingTouched = ref(false)
const openAICompactMode = ref<OpenAICompactMode>('auto')
@@ -4231,6 +4264,7 @@ watch(
}
if (newPlatform !== 'openai') {
openaiPassthroughEnabled.value = false
openaiFlattenNamespacesEnabled.value = false
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
@@ -4656,6 +4690,7 @@ const resetForm = () => {
interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false
openaiFlattenNamespacesEnabled.value = false
openAILongContextBillingEnabled.value = false
openAILongContextBillingTouched.value = false
openAICompactMode.value = 'auto'
@@ -4740,6 +4775,12 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
delete extra.openai_passthrough
delete extra.openai_oauth_passthrough
}
// 缺省即保留 namespace,不写空值,避免 extra 里堆积默认项
if (form.type === 'oauth' && openaiFlattenNamespacesEnabled.value) {
extra.openai_responses_flatten_namespaces = true
} else {
delete extra.openai_responses_flatten_namespaces
}
extra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value
if (accountCategory.value === 'oauth-based' && codexCLIOnlyEnabled.value) {
@@ -1481,6 +1481,37 @@
</div>
</div>
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 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">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.flattenNamespaces') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
</p>
</div>
<button
type="button"
data-testid="edit-openai-flatten-namespaces-toggle"
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
: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',
openaiFlattenNamespacesEnabled ? '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',
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- OpenAI Codex hosted image_generation bridge policy -->
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
@@ -2841,6 +2872,8 @@ const customBaseUrl = ref('')
// OpenAI 自动透传开关(OAuth/API Key)
const openaiPassthroughEnabled = ref(false)
// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
const openaiFlattenNamespacesEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
// OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别
const editPlanType = ref<string>('')
@@ -3276,6 +3309,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
openaiPassthroughEnabled.value = false
openaiFlattenNamespacesEnabled.value = false
openAILongContextBillingEnabled.value = false
editPlanType.value = ''
openAICompactMode.value = 'auto'
@@ -3292,6 +3326,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
webSearchEmulationMode.value = 'default'
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
openaiFlattenNamespacesEnabled.value =
newAccount.type === 'oauth' && extra?.openai_responses_flatten_namespaces === true
const longContextBillingValue = extra?.openai_long_context_billing_enabled
openAILongContextBillingEnabled.value = longContextBillingValue === true
// plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填
@@ -4526,6 +4562,12 @@ const handleSubmit = async () => {
delete newExtra.openai_passthrough
delete newExtra.openai_oauth_passthrough
}
// 缺省即保留 namespace,不写空值,避免 extra 里堆积默认项
if (props.account.type === 'oauth' && openaiFlattenNamespacesEnabled.value) {
newExtra.openai_responses_flatten_namespaces = true
} else {
delete newExtra.openai_responses_flatten_namespaces
}
if (isSparkShadow.value) {
delete newExtra.openai_long_context_billing_enabled
} else {
@@ -237,6 +237,34 @@ describe('BulkEditAccountModal', () => {
})
})
it('OpenAI OAuth 批量编辑可开启 namespace 摊平兼容开关', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
selectedTypes: ['oauth']
})
await wrapper.get('#bulk-edit-openai-flatten-namespaces-enabled').setValue(true)
await wrapper.get('#bulk-edit-openai-flatten-namespaces-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: {
openai_responses_flatten_namespaces: true
}
})
})
it('namespace 摊平开关不对 setup-token 等非 OAuth 选择展示', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
selectedTypes: ['oauth', 'setup-token']
})
expect(wrapper.find('#bulk-edit-openai-flatten-namespaces-enabled').exists()).toBe(false)
})
it('OpenAI OAuth 批量编辑应提交 OAuth 专属 WS mode 字段(含 http_bridge)', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
@@ -167,6 +167,21 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
// namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平
it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe(
true
)
await selectButtonByText(wrapper, 'API Key')
expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe(
false
)
})
it('enables upstream billing probes by default for new OpenAI API key accounts', async () => {
await submitApiKeyAccount('openai')
@@ -416,6 +416,57 @@ describe('EditAccountModal', () => {
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('loads and clears the OAuth-only Codex namespace flatten toggle', async () => {
const account = buildAccount()
account.type = 'oauth'
account.extra = {
openai_responses_flatten_namespaces: true
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="edit-openai-flatten-namespaces-toggle"]')
// 关闭后应从 extra 中删除该键,而不是写入 false
await toggle.trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty(
'openai_responses_flatten_namespaces'
)
})
it('submits the Codex namespace flatten toggle when switched on', async () => {
const account = buildAccount()
account.type = 'oauth'
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('[data-testid="edit-openai-flatten-namespaces-toggle"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_flatten_namespaces).toBe(
true
)
})
it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => {
const account = buildAccount()
const wrapper = mountModal(account)
expect(wrapper.find('[data-testid="edit-openai-flatten-namespaces-toggle"]').exists()).toBe(
false
)
})
it('defaults legacy OpenAI accounts to long-context billing disabled', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
@@ -489,6 +489,9 @@ export default {
oauthPassthrough: 'Auto passthrough (auth only)',
oauthPassthroughDesc:
'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.',
flattenNamespaces: 'Flatten Codex namespace tools (compatibility)',
flattenNamespacesDesc:
'Disabled by default: Codex namespace tool declarations are forwarded as-is on /responses, which is what the ChatGPT Codex backend expects. Enable only when this OAuth account is routed to a relay that rejects namespace tools — flattening renames them to namespace__tool, which breaks models that address collaboration tools as functions.<namespace>.<tool>. Compaction requests always flatten regardless of this switch.',
longContextBilling: 'API long-context pricing',
longContextBillingDesc:
'Disabled by default. Enable only when this account\'s upstream charges OpenAI API long-context rates above the model threshold.',
@@ -556,6 +556,9 @@ export default {
oauthPassthrough: '自动透传(仅替换认证)',
oauthPassthroughDesc:
'开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。',
flattenNamespaces: '摊平 Codex namespace 工具(兼容)',
flattenNamespacesDesc:
'默认关闭:/responses 上的 namespace 工具声明原样转发,这正是 ChatGPT Codex 后端期望的形态。仅当该 OAuth 账号指向不认识 namespace 的兼容上游时才开启——摊平会把工具改名为 namespace__tool,使按 functions.<命名空间>.<工具> 寻址的模型(如 gpt-5.6 多智能体)无法调用。压缩(compact)请求不受该开关影响,始终摊平。',
longContextBilling: 'API 长上下文计费',
longContextBillingDesc: '默认关闭。仅当该账号的上游会按模型阈值收取 OpenAI API 长上下文费率时开启。',
responsesWebsocketsV2: 'Responses WebSocket v2',