feat(frontend): add account Codex image bridge control
This commit is contained in:
parent
45b1e6ae41
commit
7a9c1d7edd
@ -1317,6 +1317,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Codex 图片生成桥接账号级覆盖 -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg border border-sky-100 bg-sky-50/60 shadow-sm dark:border-sky-900/50 dark:bg-sky-950/20">
|
||||
<div class="flex items-start gap-3 px-4 py-3">
|
||||
<div class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-white text-sky-600 shadow-sm ring-1 ring-sky-100 dark:bg-dark-800 dark:text-sky-300 dark:ring-sky-900/60">
|
||||
<Icon name="sparkles" size="sm" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexImageGenerationBridge') }}</label>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
:class="codexImageGenerationBridgeBadgeClass"
|
||||
>
|
||||
{{ codexImageGenerationBridgeBadgeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">
|
||||
{{ t('admin.accounts.openai.codexImageGenerationBridgeDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-sky-100 bg-white/70 p-2 dark:border-sky-900/50 dark:bg-dark-800/70">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="option in codexImageGenerationBridgeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:data-testid="`codex-image-bridge-${option.value}`"
|
||||
@click="codexImageGenerationBridgeMode = option.value"
|
||||
:class="[
|
||||
'group flex min-h-[68px] items-start gap-2 rounded-md border px-3 py-2 text-left transition-all',
|
||||
codexImageGenerationBridgeMode === option.value
|
||||
? 'border-sky-300 bg-sky-50 text-sky-900 shadow-sm ring-1 ring-sky-200 dark:border-sky-700 dark:bg-sky-900/25 dark:text-sky-100 dark:ring-sky-800'
|
||||
: 'border-transparent bg-transparent text-slate-600 hover:border-gray-200 hover:bg-gray-50 dark:text-slate-300 dark:hover:border-dark-500 dark:hover:bg-dark-700'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||
codexImageGenerationBridgeMode === option.value
|
||||
? 'border-sky-500 bg-sky-500 text-white'
|
||||
: 'border-gray-300 text-transparent group-hover:border-gray-400 dark:border-dark-500'
|
||||
]"
|
||||
>
|
||||
<Icon name="check" size="xs" :stroke-width="2" />
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-medium">{{ option.label }}</span>
|
||||
<span class="mt-0.5 block text-xs leading-4 text-slate-500 dark:text-slate-400">{{ option.description }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI WS Mode 三态(off/ctx_pool/passthrough) -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'apikey')"
|
||||
@ -2275,6 +2335,8 @@ const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled'
|
||||
const codexImageGenerationBridgeMode = ref<CodexImageGenerationBridgeMode>('inherit')
|
||||
const anthropicPassthroughEnabled = ref(false)
|
||||
const webSearchEmulationMode = ref('default')
|
||||
const webSearchGlobalEnabled = ref(false)
|
||||
@ -2325,6 +2387,47 @@ const openaiResponsesWebSocketV2Mode = computed({
|
||||
const openAIWSModeConcurrencyHintKey = computed(() =>
|
||||
resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value)
|
||||
)
|
||||
const codexImageGenerationBridgeOptions = computed<Array<{
|
||||
value: CodexImageGenerationBridgeMode
|
||||
label: string
|
||||
description: string
|
||||
}>>(() => [
|
||||
{
|
||||
value: 'inherit',
|
||||
label: t('admin.accounts.openai.codexImageGenerationBridgeInherit'),
|
||||
description: t('admin.accounts.openai.codexImageGenerationBridgeInheritDesc')
|
||||
},
|
||||
{
|
||||
value: 'enabled',
|
||||
label: t('admin.accounts.openai.codexImageGenerationBridgeEnabled'),
|
||||
description: t('admin.accounts.openai.codexImageGenerationBridgeEnabledDesc')
|
||||
},
|
||||
{
|
||||
value: 'disabled',
|
||||
label: t('admin.accounts.openai.codexImageGenerationBridgeDisabled'),
|
||||
description: t('admin.accounts.openai.codexImageGenerationBridgeDisabledDesc')
|
||||
}
|
||||
])
|
||||
const codexImageGenerationBridgeBadgeLabel = computed(() => {
|
||||
switch (codexImageGenerationBridgeMode.value) {
|
||||
case 'enabled':
|
||||
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeEnabled')
|
||||
case 'disabled':
|
||||
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeDisabled')
|
||||
default:
|
||||
return t('admin.accounts.openai.codexImageGenerationBridgeBadgeInherit')
|
||||
}
|
||||
})
|
||||
const codexImageGenerationBridgeBadgeClass = computed(() => {
|
||||
switch (codexImageGenerationBridgeMode.value) {
|
||||
case 'enabled':
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
case 'disabled':
|
||||
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-600 dark:bg-dark-600 dark:text-slate-300'
|
||||
}
|
||||
})
|
||||
const openAICompactModeOptions = computed(() => [
|
||||
{ value: 'auto', label: t('admin.accounts.openai.compactModeAuto') },
|
||||
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
|
||||
@ -2344,7 +2447,7 @@ const openAICompactStatusKey = computed(() => {
|
||||
? 'admin.accounts.openai.compactSupported'
|
||||
: 'admin.accounts.openai.compactUnsupported'
|
||||
}
|
||||
return 'admin.accounts.openai.compactUnknown'
|
||||
return 'admin.accounts.openai.compactAuto'
|
||||
})
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
@ -2483,11 +2586,20 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
codexImageGenerationBridgeMode.value = 'inherit'
|
||||
anthropicPassthroughEnabled.value = false
|
||||
webSearchEmulationMode.value = 'default'
|
||||
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
|
||||
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
|
||||
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
|
||||
const codexImageGenerationBridgeValue = typeof extra?.codex_image_generation_bridge === 'boolean'
|
||||
? extra.codex_image_generation_bridge
|
||||
: extra?.codex_image_generation_bridge_enabled
|
||||
if (codexImageGenerationBridgeValue === true) {
|
||||
codexImageGenerationBridgeMode.value = 'enabled'
|
||||
} else if (codexImageGenerationBridgeValue === false) {
|
||||
codexImageGenerationBridgeMode.value = 'disabled'
|
||||
}
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
|
||||
modeKey: 'openai_oauth_responses_websockets_v2_mode',
|
||||
enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
|
||||
@ -3610,6 +3722,13 @@ const handleSubmit = async () => {
|
||||
newExtra.openai_compact_mode = openAICompactMode.value
|
||||
}
|
||||
|
||||
delete newExtra.codex_image_generation_bridge_enabled
|
||||
if (codexImageGenerationBridgeMode.value === 'inherit') {
|
||||
delete newExtra.codex_image_generation_bridge
|
||||
} else {
|
||||
newExtra.codex_image_generation_bridge = codexImageGenerationBridgeMode.value === 'enabled'
|
||||
}
|
||||
|
||||
if (props.account.type === 'oauth') {
|
||||
if (codexCLIOnlyEnabled.value) {
|
||||
newExtra.codex_cli_only = true
|
||||
|
||||
@ -216,4 +216,25 @@ describe('EditAccountModal', () => {
|
||||
'gpt-5.4': 'gpt-5.4-openai-compact'
|
||||
})
|
||||
})
|
||||
|
||||
it('submits account-level Codex image generation bridge override', async () => {
|
||||
const account = buildAccount()
|
||||
account.extra = {
|
||||
codex_image_generation_bridge: false,
|
||||
codex_image_generation_bridge_enabled: true
|
||||
}
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
await wrapper.get('button[data-testid="codex-image-bridge-enabled"]').trigger('click')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_bridge).toBe(true)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled')
|
||||
})
|
||||
})
|
||||
|
||||
@ -3136,6 +3136,18 @@ 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.',
|
||||
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.',
|
||||
codexImageGenerationBridgeInherit: 'Follow channel',
|
||||
codexImageGenerationBridgeInheritDesc: 'Do not write an account override; use the channel or global policy.',
|
||||
codexImageGenerationBridgeEnabled: 'Force on',
|
||||
codexImageGenerationBridgeEnabledDesc: 'Allow image tool injection for Codex /responses requests.',
|
||||
codexImageGenerationBridgeDisabled: 'Force off',
|
||||
codexImageGenerationBridgeDisabledDesc: 'Block image tool injection for Codex /responses requests.',
|
||||
codexImageGenerationBridgeBadgeInherit: 'Channel policy',
|
||||
codexImageGenerationBridgeBadgeEnabled: 'Account on',
|
||||
codexImageGenerationBridgeBadgeDisabled: 'Account off',
|
||||
compactMode: 'Compact mode',
|
||||
compactModeDesc:
|
||||
'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.',
|
||||
@ -3147,7 +3159,8 @@ export default {
|
||||
'Only applies to /responses/compact. Use this when the upstream compact endpoint requires a special compact model.',
|
||||
compactSupported: 'Compact supported',
|
||||
compactUnsupported: 'Compact unsupported',
|
||||
compactUnknown: 'Compact unknown',
|
||||
compactAuto: 'Compact Auto',
|
||||
compactUnknown: 'Compact Auto',
|
||||
compactLastChecked: 'Last compact probe',
|
||||
testMode: 'Test mode',
|
||||
testModeDefault: 'Default request',
|
||||
|
||||
@ -3281,6 +3281,18 @@ export default {
|
||||
responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。',
|
||||
codexCLIOnly: '仅允许 Codex 官方客户端',
|
||||
codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。',
|
||||
codexImageGenerationBridge: 'Codex 图片生成桥接',
|
||||
codexImageGenerationBridgeDesc:
|
||||
'账号级策略优先于渠道和全局配置。仅控制 Codex 走 /responses 文本端点时是否注入 image_generation 工具;不影响独立图片生成接口。',
|
||||
codexImageGenerationBridgeInherit: '跟随渠道',
|
||||
codexImageGenerationBridgeInheritDesc: '不写入账号覆盖,继续使用渠道或全局策略。',
|
||||
codexImageGenerationBridgeEnabled: '强制开启',
|
||||
codexImageGenerationBridgeEnabledDesc: '允许 Codex /responses 请求获得图片工具注入。',
|
||||
codexImageGenerationBridgeDisabled: '强制关闭',
|
||||
codexImageGenerationBridgeDisabledDesc: '阻断 Codex /responses 的图片工具注入。',
|
||||
codexImageGenerationBridgeBadgeInherit: '渠道策略',
|
||||
codexImageGenerationBridgeBadgeEnabled: '账号开启',
|
||||
codexImageGenerationBridgeBadgeDisabled: '账号关闭',
|
||||
compactMode: 'Compact 模式',
|
||||
compactModeDesc:
|
||||
'控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。',
|
||||
@ -3292,7 +3304,8 @@ export default {
|
||||
'仅在 /responses/compact 请求中生效。当上游 compact 端点需要特殊 compact 模型时使用。',
|
||||
compactSupported: '支持 Compact',
|
||||
compactUnsupported: '不支持 Compact',
|
||||
compactUnknown: 'Compact 未知',
|
||||
compactAuto: 'Compact Auto',
|
||||
compactUnknown: 'Compact Auto',
|
||||
compactLastChecked: '最近探测',
|
||||
testMode: '测试模式',
|
||||
testModeDefault: '常规请求',
|
||||
|
||||
@ -196,21 +196,27 @@
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
<template #cell-platform_type="{ row }">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" :privacy-mode="row.extra?.privacy_mode" :subscription-expires-at="row.credentials?.subscription_expires_at" />
|
||||
<span
|
||||
v-if="getOpenAICompactLabel(row)"
|
||||
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getOpenAICompactClass(row)]"
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" :privacy-mode="row.extra?.privacy_mode" :subscription-expires-at="row.credentials?.subscription_expires_at" />
|
||||
<span
|
||||
v-if="getAntigravityTierLabel(row)"
|
||||
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]"
|
||||
>
|
||||
{{ getAntigravityTierLabel(row) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="getOpenAICompactMeta(row)"
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 pl-0.5 text-[11px] font-medium leading-4',
|
||||
getOpenAICompactMeta(row)?.className
|
||||
]"
|
||||
:title="getOpenAICompactTitle(row)"
|
||||
>
|
||||
{{ getOpenAICompactLabel(row) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getAntigravityTierLabel(row)"
|
||||
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]"
|
||||
>
|
||||
{{ getAntigravityTierLabel(row) }}
|
||||
</span>
|
||||
<span :class="['h-1.5 w-1.5 rounded-full', getOpenAICompactMeta(row)?.dotClass]" />
|
||||
<span>{{ getOpenAICompactMeta(row)?.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-capacity="{ row }">
|
||||
@ -983,41 +989,51 @@ function getAntigravityTierLabel(row: any): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenAICompactState(row: any): 'supported' | 'unsupported' | 'unknown' | null {
|
||||
type OpenAICompactBadgeState = 'active' | 'blocked' | 'auto'
|
||||
|
||||
function getOpenAICompactState(row: any): OpenAICompactBadgeState | null {
|
||||
if (row.platform !== 'openai' || (row.type !== 'oauth' && row.type !== 'apikey')) return null
|
||||
const extra = row.extra as Record<string, unknown> | undefined
|
||||
const mode = typeof extra?.openai_compact_mode === 'string' ? extra.openai_compact_mode : 'auto'
|
||||
if (mode === 'force_on') return 'supported'
|
||||
if (mode === 'force_off') return 'unsupported'
|
||||
if (mode === 'force_on') return 'active'
|
||||
if (mode === 'force_off') return 'blocked'
|
||||
if (typeof extra?.openai_compact_supported === 'boolean') {
|
||||
return extra.openai_compact_supported ? 'supported' : 'unsupported'
|
||||
return extra.openai_compact_supported ? 'active' : 'blocked'
|
||||
}
|
||||
return 'unknown'
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function getOpenAICompactLabel(row: any): string | null {
|
||||
switch (getOpenAICompactState(row)) {
|
||||
case 'supported': return t('admin.accounts.openai.compactSupported')
|
||||
case 'unsupported': return t('admin.accounts.openai.compactUnsupported')
|
||||
case 'unknown': return t('admin.accounts.openai.compactUnknown')
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenAICompactClass(row: any): string {
|
||||
switch (getOpenAICompactState(row)) {
|
||||
case 'supported': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
case 'unsupported': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300'
|
||||
case 'unknown': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
default: return ''
|
||||
function getOpenAICompactMeta(row: any): { label: string; className: string; dotClass: string } | null {
|
||||
const state = getOpenAICompactState(row)
|
||||
if (!state) return null
|
||||
switch (state) {
|
||||
case 'active':
|
||||
return {
|
||||
label: t('admin.accounts.openai.compactSupported'),
|
||||
className: 'text-emerald-600 dark:text-emerald-300',
|
||||
dotClass: 'bg-emerald-500 shadow-[0_0_0_2px_rgba(16,185,129,0.14)]'
|
||||
}
|
||||
case 'blocked':
|
||||
return {
|
||||
label: t('admin.accounts.openai.compactUnsupported'),
|
||||
className: 'text-rose-600 dark:text-rose-300',
|
||||
dotClass: 'bg-rose-500 shadow-[0_0_0_2px_rgba(244,63,94,0.14)]'
|
||||
}
|
||||
case 'auto':
|
||||
return {
|
||||
label: t('admin.accounts.openai.compactAuto'),
|
||||
className: 'text-slate-500 dark:text-slate-400',
|
||||
dotClass: 'bg-slate-300 dark:bg-slate-500'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenAICompactTitle(row: any): string {
|
||||
const extra = row.extra as Record<string, unknown> | undefined
|
||||
const checkedAt = typeof extra?.openai_compact_checked_at === 'string' ? extra.openai_compact_checked_at : ''
|
||||
if (!checkedAt) return getOpenAICompactLabel(row) || ''
|
||||
return `${getOpenAICompactLabel(row)} | ${t('admin.accounts.openai.compactLastChecked')}: ${formatDateTime(new Date(checkedAt))}`
|
||||
const label = getOpenAICompactMeta(row)?.label || ''
|
||||
if (!checkedAt) return label
|
||||
return `${label} | ${t('admin.accounts.openai.compactLastChecked')}: ${formatDateTime(new Date(checkedAt))}`
|
||||
}
|
||||
|
||||
function getAntigravityTierClass(row: any): string {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user