diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts
index 21ce9f092f..73bfd29af3 100644
--- a/frontend/src/api/admin/accounts.ts
+++ b/frontend/src/api/admin/accounts.ts
@@ -470,6 +470,7 @@ export async function bulkUpdate(
failed: number
success_ids?: number[]
failed_ids?: number[]
+ long_context_inherited_count?: number
results: Array<{ account_id: number; success: boolean; error?: string }>
}> {
const payload = Array.isArray(accountIdsOrPayload)
@@ -483,6 +484,7 @@ export async function bulkUpdate(
failed: number
success_ids?: number[]
failed_ids?: number[]
+ long_context_inherited_count?: number
results: Array<{ account_id: number; success: boolean; error?: string }>
}>('/admin/accounts/bulk-update', payload)
return data
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index 86379b6bb3..e8159c7bfb 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -133,6 +133,66 @@
+
+
@@ -1321,7 +1476,15 @@ import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
-import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType, OpenAICompactMode } from '@/types'
+import type {
+ Proxy as ProxyConfig,
+ AdminGroup,
+ AccountPlatform,
+ AccountType,
+ OpenAICompactMode,
+ OpenAIEndpointCapability,
+ OpenAIResponsesMode
+} from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
@@ -1495,6 +1658,9 @@ const enableStatus = ref(false)
const enableGroups = ref(false)
const enableOpenAIPassthrough = ref(false)
const enableOpenAIFlattenNamespaces = ref(false)
+const enableOpenAILongContextBilling = ref(false)
+const enableOpenAIEndpointCapabilities = ref(false)
+const enableOpenAIResponsesMode = ref(false)
const enableOpenAIWSMode = ref(false)
const enableOpenAIAPIKeyWSMode = ref(false)
const enableUpstreamBillingAutoProbe = ref(false)
@@ -1528,6 +1694,12 @@ const groupIds = ref([])
const openaiPassthroughEnabled = ref(false)
// Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
const openaiFlattenNamespacesEnabled = ref(false)
+const openAILongContextBillingEnabled = ref(false)
+const openAIEndpointCapabilities = ref([
+ 'chat_completions',
+ 'embeddings'
+])
+const openAIResponsesMode = ref('auto')
const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF)
const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled')
@@ -1592,6 +1764,65 @@ const openAICompactModeOptions = computed(() => [
{ value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') },
{ value: 'force_off', label: t('admin.accounts.openai.compactModeForceOff') }
])
+const openAIResponsesModeOptions = computed(() => [
+ { value: 'auto', label: t('admin.accounts.openai.responsesModeAuto') },
+ { value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
+ {
+ value: 'force_chat_completions',
+ label: t('admin.accounts.openai.responsesModeForceChatCompletions')
+ }
+])
+const openAITextEndpointCapabilityLabel = computed(() => {
+ if (openAIResponsesMode.value === 'force_responses') {
+ return t('admin.accounts.openai.capabilityResponses')
+ }
+ if (openAIResponsesMode.value === 'force_chat_completions') {
+ return t('admin.accounts.openai.capabilityChatCompletions')
+ }
+ return t('admin.accounts.openai.capabilityTextAuto')
+})
+const openAIEndpointCapabilityOptions = computed<
+ Array<{ value: OpenAIEndpointCapability; label: string }>
+>(() => [
+ { value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value },
+ { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }
+])
+const openAITextGenerationCapabilityEnabled = computed(() =>
+ openAIEndpointCapabilities.value.includes('chat_completions')
+)
+const openAIResponsesModeApplicable = computed(
+ () => !enableOpenAIEndpointCapabilities.value || openAITextGenerationCapabilityEnabled.value
+)
+
+const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => {
+ const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings']
+ const selected = allowed.filter((value) => values.includes(value))
+ return selected.length > 0 ? selected : allowed
+}
+
+const toggleOpenAIEndpointCapability = (
+ capability: OpenAIEndpointCapability,
+ event?: Event
+) => {
+ if (openAIEndpointCapabilities.value.includes(capability)) {
+ if (openAIEndpointCapabilities.value.length <= 1) {
+ const input = event?.target as HTMLInputElement | null
+ if (input) input.checked = true
+ return
+ }
+ openAIEndpointCapabilities.value = openAIEndpointCapabilities.value.filter(
+ (value) => value !== capability
+ )
+ if (!openAITextGenerationCapabilityEnabled.value) {
+ openAIResponsesMode.value = 'auto'
+ }
+ return
+ }
+ openAIEndpointCapabilities.value = normalizeOpenAIEndpointCapabilities([
+ ...openAIEndpointCapabilities.value,
+ capability
+ ])
+}
const openAIWSModeConcurrencyHintKey = computed(() =>
resolveOpenAIWSModeConcurrencyHintKey(openaiOAuthResponsesWebSocketV2Mode.value)
)
@@ -1692,6 +1923,11 @@ const buildUpdatePayload = (): Record | null => {
const updates: Record = {}
const credentials: Record = {}
let credentialsChanged = false
+ const applyOpenAILongContextBilling =
+ enableOpenAILongContextBilling.value && allOpenAIPassthroughCapable.value
+ const applyOpenAIEndpointCapabilities =
+ enableOpenAIEndpointCapabilities.value && allOpenAIAPIKey.value
+ const applyOpenAIResponsesMode = enableOpenAIResponsesMode.value && allOpenAIAPIKey.value
const ensureExtra = (): Record => {
if (!updates.extra) {
updates.extra = {}
@@ -1752,6 +1988,30 @@ const buildUpdatePayload = (): Record | null => {
extra.openai_responses_flatten_namespaces = openaiFlattenNamespacesEnabled.value
}
+ if (applyOpenAILongContextBilling) {
+ const extra = ensureExtra()
+ extra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value
+ }
+
+ if (applyOpenAIEndpointCapabilities) {
+ credentials.openai_capabilities =
+ openAIEndpointCapabilities.value.length === 2
+ ? null
+ : [...openAIEndpointCapabilities.value]
+ credentialsChanged = true
+ }
+
+ if (
+ applyOpenAIResponsesMode ||
+ (applyOpenAIEndpointCapabilities && !openAITextGenerationCapabilityEnabled.value)
+ ) {
+ const extra = ensureExtra()
+ extra.openai_responses_mode =
+ !openAIResponsesModeApplicable.value || openAIResponsesMode.value === 'auto'
+ ? null
+ : openAIResponsesMode.value
+ }
+
if (enableModelRestriction.value && !isOpenAIModelRestrictionDisabled.value) {
// 统一使用 model_mapping 字段
if (modelRestrictionMode.value === 'whitelist') {
@@ -1931,6 +2191,9 @@ const handleSubmit = async () => {
enableBaseUrl.value ||
enableOpenAIPassthrough.value ||
enableOpenAIFlattenNamespaces.value ||
+ (enableOpenAILongContextBilling.value && allOpenAIPassthroughCapable.value) ||
+ (enableOpenAIEndpointCapabilities.value && allOpenAIAPIKey.value) ||
+ (enableOpenAIResponsesMode.value && allOpenAIAPIKey.value) ||
enableModelRestriction.value ||
enableCustomErrorCodes.value ||
enableInterceptWarmup.value ||
@@ -2011,11 +2274,22 @@ const submitBulkUpdate = async (baseUpdates: Record) => {
: await adminAPI.accounts.bulkUpdate(props.accountIds, updates)
const success = res.success || 0
const failed = res.failed || 0
+ const inherited = res.long_context_inherited_count || 0
if (success > 0 && failed === 0) {
- appStore.showSuccess(t('admin.accounts.bulkEdit.success', { count: success }))
+ if (inherited > 0) {
+ appStore.showSuccess(t('admin.accounts.bulkEdit.successWithInherited', {
+ count: success,
+ inherited
+ }))
+ } else {
+ appStore.showSuccess(t('admin.accounts.bulkEdit.success', { count: success }))
+ }
} else if (success > 0) {
- appStore.showError(t('admin.accounts.bulkEdit.partialSuccess', { success, failed }))
+ const key = inherited > 0
+ ? 'admin.accounts.bulkEdit.partialSuccessWithInherited'
+ : 'admin.accounts.bulkEdit.partialSuccess'
+ appStore.showError(t(key, { success, failed, inherited }))
} else {
appStore.showError(t('admin.accounts.bulkEdit.failed'))
}
@@ -2035,6 +2309,8 @@ const submitBulkUpdate = async (baseUpdates: Record) => {
appStore.showError(t('admin.accounts.bulkEdit.rateSyncConflict', {
count: error.metadata?.count ?? 1
}))
+ } else if (error.reason === 'OPENAI_LONG_CONTEXT_PARENT_REQUIRED') {
+ appStore.showError(t('admin.accounts.bulkEdit.longContextParentRequired'))
} else {
appStore.showError(error.message || t('admin.accounts.bulkEdit.failed'))
console.error('Error bulk updating accounts:', error)
@@ -2077,6 +2353,9 @@ watch(
enableGroups.value = false
enableOpenAIPassthrough.value = false
enableOpenAIFlattenNamespaces.value = false
+ enableOpenAILongContextBilling.value = false
+ enableOpenAIEndpointCapabilities.value = false
+ enableOpenAIResponsesMode.value = false
enableOpenAIWSMode.value = false
enableOpenAIAPIKeyWSMode.value = false
enableUpstreamBillingAutoProbe.value = false
@@ -2092,6 +2371,9 @@ watch(
baseUrl.value = ''
openaiPassthroughEnabled.value = false
openaiFlattenNamespacesEnabled.value = false
+ openAILongContextBillingEnabled.value = false
+ openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
+ openAIResponsesMode.value = 'auto'
modelRestrictionMode.value = 'whitelist'
allowedModels.value = []
modelMappings.value = []
diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
index 45b8473cbf..dec8be054a 100644
--- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
@@ -1,17 +1,20 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
import BulkEditAccountModal from '../BulkEditAccountModal.vue'
import ModelWhitelistSelector from '../ModelWhitelistSelector.vue'
import { adminAPI } from '@/api/admin'
-const { showError } = vi.hoisted(() => ({
- showError: vi.fn()
+const { showError, showSuccess, translate } = vi.hoisted(() => ({
+ showError: vi.fn(),
+ showSuccess: vi.fn(),
+ translate: vi.fn((key: string) => key)
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
- showSuccess: vi.fn(),
+ showSuccess,
showInfo: vi.fn()
})
}))
@@ -34,7 +37,7 @@ vi.mock('vue-i18n', async () => {
return {
...actual,
useI18n: () => ({
- t: (key: string) => key
+ t: translate
})
}
})
@@ -82,6 +85,8 @@ describe('BulkEditAccountModal', () => {
vi.mocked(adminAPI.accounts.bulkUpdate).mockReset()
vi.mocked(adminAPI.accounts.checkMixedChannelRisk).mockReset()
showError.mockReset()
+ showSuccess.mockReset()
+ translate.mockClear()
vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValue({
success: 2,
@@ -403,6 +408,302 @@ describe('BulkEditAccountModal', () => {
})
})
+ it('OpenAI 支持类型展示长上下文设置,混合平台隐藏全部新增设置', () => {
+ for (const selectedTypes of [['oauth'], ['setup-token'], ['apikey'], ['oauth', 'setup-token', 'apikey']]) {
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes
+ })
+ expect(wrapper.find('#bulk-edit-openai-long-context-billing-enabled').exists()).toBe(true)
+ wrapper.unmount()
+ }
+
+ const mixed = mountModal({
+ selectedPlatforms: ['openai', 'anthropic'],
+ selectedTypes: ['apikey']
+ })
+ expect(mixed.find('#bulk-edit-openai-long-context-billing-enabled').exists()).toBe(false)
+ expect(mixed.find('#bulk-edit-openai-endpoint-capabilities-enabled').exists()).toBe(false)
+ expect(mixed.find('#bulk-edit-openai-responses-mode-enabled').exists()).toBe(false)
+ })
+
+ it('端点能力与 Responses 路由仅对全部 OpenAI API Key 目标展示', () => {
+ const apiKey = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['apikey']
+ })
+ expect(apiKey.find('#bulk-edit-openai-endpoint-capabilities-enabled').exists()).toBe(true)
+ expect(apiKey.find('#bulk-edit-openai-responses-mode-enabled').exists()).toBe(true)
+ apiKey.unmount()
+
+ const oauth = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['oauth']
+ })
+ expect(oauth.find('#bulk-edit-openai-endpoint-capabilities-enabled').exists()).toBe(false)
+ expect(oauth.find('#bulk-edit-openai-responses-mode-enabled').exists()).toBe(false)
+ })
+
+ it('长上下文设置独立启用并提交布尔值', async () => {
+ const enabledWrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['oauth']
+ })
+
+ await enabledWrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
+ await enabledWrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').trigger('click')
+ await enabledWrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenLastCalledWith([1, 2], {
+ extra: { openai_long_context_billing_enabled: true }
+ })
+ enabledWrapper.unmount()
+
+ vi.mocked(adminAPI.accounts.bulkUpdate).mockClear()
+ const disabledWrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['setup-token']
+ })
+ await disabledWrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
+ await disabledWrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ extra: { openai_long_context_billing_enabled: false }
+ })
+ })
+
+ it('端点能力默认值提交 null,表示恢复两个默认端点', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['apikey']
+ })
+
+ await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ credentials: { openai_capabilities: null }
+ })
+ })
+
+ it('Responses 路由独立启用,auto 提交 null,强制模式提交明确值', async () => {
+ const autoWrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['apikey']
+ })
+ await autoWrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
+ await autoWrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenLastCalledWith([1, 2], {
+ extra: { openai_responses_mode: null }
+ })
+ autoWrapper.unmount()
+
+ vi.mocked(adminAPI.accounts.bulkUpdate).mockClear()
+ const forcedWrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['apikey']
+ })
+ await forcedWrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
+ await forcedWrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').setValue('force_responses')
+ await forcedWrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ extra: { openai_responses_mode: 'force_responses' }
+ })
+ })
+
+ it('仅启用 Embeddings 时恢复 Responses 自动模式并精确提交联动字段', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['apikey']
+ })
+
+ await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
+ await wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').setValue('force_chat_completions')
+ await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false)
+
+ expect((wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').element as HTMLSelectElement).value)
+ .toBe('auto')
+ expect(wrapper.find('[data-testid="bulk-edit-openai-responses-mode-not-applicable"]').exists()).toBe(true)
+
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ credentials: { openai_capabilities: ['embeddings'] },
+ extra: { openai_responses_mode: null }
+ })
+ })
+
+ it('关闭端点能力修改后 Responses 路由恢复独立可编辑', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['apikey']
+ })
+
+ await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
+ await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false)
+ await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(false)
+ await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
+
+ const select = wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]')
+ expect(select.attributes('disabled')).toBeUndefined()
+ await select.setValue('force_responses')
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ extra: { openai_responses_mode: 'force_responses' }
+ })
+ })
+
+ it('目标变化后不提交已经隐藏的 OpenAI 设置', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['apikey']
+ })
+
+ await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
+ await wrapper.setProps({ selectedPlatforms: ['anthropic'], selectedTypes: ['apikey'] })
+ await wrapper.get('#bulk-edit-status-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ status: 'active'
+ })
+ })
+
+ it('至少保留一个端点能力', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['apikey']
+ })
+ await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
+ await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false)
+ await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-embeddings"]').setValue(false)
+
+ expect((wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-embeddings"]').element as HTMLInputElement).checked)
+ .toBe(true)
+ })
+
+ it('关闭弹窗后重置新增设置的启用状态和值', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['apikey']
+ })
+ await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
+ await wrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').trigger('click')
+ await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
+ await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').setValue(false)
+ await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true)
+
+ await wrapper.setProps({ show: false })
+ await nextTick()
+
+ expect((wrapper.get('#bulk-edit-openai-long-context-billing-enabled').element as HTMLInputElement).checked).toBe(false)
+ expect(wrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').attributes('aria-checked')).toBe('false')
+ expect((wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').element as HTMLInputElement).checked).toBe(false)
+ expect((wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-chat_completions"]').element as HTMLInputElement).checked).toBe(true)
+ expect((wrapper.get('[data-testid="bulk-edit-openai-responses-mode-select"]').element as HTMLSelectElement).value).toBe('auto')
+ })
+
+ it('筛选全量模式固定展示影子继承说明并按 filters 提交', async () => {
+ const wrapper = mountModal({
+ accountIds: [],
+ selectedPlatforms: [],
+ selectedTypes: [],
+ target: {
+ mode: 'filtered',
+ filters: { platform: 'openai', type: 'oauth', status: 'active' },
+ previewCount: 20,
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['oauth']
+ }
+ })
+
+ expect(wrapper.get('[data-testid="bulk-edit-openai-long-context-shadow-hint"]').text())
+ .toContain('admin.accounts.bulkEdit.longContextShadowHint')
+ await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
+ await wrapper.get('[data-testid="bulk-edit-openai-long-context-billing-toggle"]').trigger('click')
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith({
+ filters: { platform: 'openai', type: 'oauth', status: 'active' },
+ extra: { openai_long_context_billing_enabled: true }
+ })
+ })
+
+ it('成功响应包含影子继承数量时展示专用提示', async () => {
+ vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValueOnce({
+ success: 2,
+ failed: 0,
+ long_context_inherited_count: 1,
+ results: []
+ } as any)
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['oauth']
+ })
+ await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(showSuccess).toHaveBeenCalledWith('admin.accounts.bulkEdit.successWithInherited')
+ expect(translate).toHaveBeenCalledWith('admin.accounts.bulkEdit.successWithInherited', {
+ count: 2,
+ inherited: 1
+ })
+ })
+
+ it('部分成功且包含影子继承数量时展示组合提示', async () => {
+ vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValueOnce({
+ success: 1,
+ failed: 1,
+ long_context_inherited_count: 1,
+ results: []
+ } as any)
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['oauth']
+ })
+ await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.partialSuccessWithInherited')
+ expect(translate).toHaveBeenCalledWith('admin.accounts.bulkEdit.partialSuccessWithInherited', {
+ success: 1,
+ failed: 1,
+ inherited: 1
+ })
+ })
+
+ it('全影子长上下文错误使用专用提示并保持弹窗打开', async () => {
+ vi.mocked(adminAPI.accounts.bulkUpdate).mockRejectedValueOnce({
+ status: 400,
+ reason: 'OPENAI_LONG_CONTEXT_PARENT_REQUIRED',
+ message: 'select parent'
+ })
+ const wrapper = mountModal({
+ selectedPlatforms: ['openai'],
+ selectedTypes: ['oauth']
+ })
+ await wrapper.get('#bulk-edit-openai-long-context-billing-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.longContextParentRequired')
+ expect(wrapper.emitted('close')).toBeUndefined()
+ })
+
it('OpenAI API Key 批量编辑可统一开启上游倍率自动探测', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts
index 0eb245033f..0300233345 100644
--- a/frontend/src/i18n/locales/en/admin/accounts.ts
+++ b/frontend/src/i18n/locales/en/admin/accounts.ts
@@ -431,12 +431,16 @@ export default {
submit: 'Update Accounts',
updating: 'Updating...',
success: 'Updated {count} account(s)',
+ successWithInherited: 'Updated {count} account(s). {inherited} selected shadow account(s) still follow their parent account.',
partialSuccess: 'Partially updated: {success} succeeded, {failed} failed',
+ partialSuccessWithInherited: 'Partially updated: {success} succeeded, {failed} failed. {inherited} selected shadow account(s) still follow their parent account.',
failed: 'Bulk update failed',
noSelection: 'Please select accounts to edit',
noFieldsSelected: 'Select at least one field to update',
rateSyncWarning: 'Accounts with upstream rate sync enabled cannot be changed in bulk. Disable sync in the account editor first.',
rateSyncConflict: 'Cannot change account rates: {count} target account(s) have upstream rate sync enabled.',
+ longContextShadowHint: 'Long-context billing belongs to the parent account. Selected shadow accounts keep following their parent, including when targets come from a filter.',
+ longContextParentRequired: 'All selected accounts are shadows. Select the parent account to change long-context billing.',
mixedPlatformWarning: 'Selected accounts span multiple platforms ({platforms}). Model mapping presets shown are combined — ensure mappings are appropriate for each platform.'
},
bulkDeleteTitle: 'Bulk Delete Accounts',
diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts
index 1c6d000c3c..05340b101a 100644
--- a/frontend/src/i18n/locales/zh/admin/accounts.ts
+++ b/frontend/src/i18n/locales/zh/admin/accounts.ts
@@ -509,12 +509,16 @@ export default {
submit: '批量更新',
updating: '更新中...',
success: '成功更新 {count} 个账号',
+ successWithInherited: '成功更新 {count} 个账号;其中 {inherited} 个影子账号仍跟随母账号。',
partialSuccess: '部分更新成功:成功 {success} 个,失败 {failed} 个',
+ partialSuccessWithInherited: '部分更新成功:成功 {success} 个,失败 {failed} 个;其中 {inherited} 个影子账号仍跟随母账号。',
failed: '批量更新失败',
noSelection: '请选择要编辑的账号',
noFieldsSelected: '请至少选择一个要更新的字段',
rateSyncWarning: '已开启上游倍率同步的账号不能批量手工修改倍率,请先在账号编辑页关闭同步。',
rateSyncConflict: '无法修改账号倍率:{count} 个目标账号已开启上游倍率同步。',
+ longContextShadowHint: '长上下文计费归母账号所有。选中的影子账号仍跟随母账号,筛选全量目标时同样如此。',
+ longContextParentRequired: '选中的账号全部是影子账号,请选择母账号修改长上下文计费。',
mixedPlatformWarning: '所选账号跨越多个平台({platforms})。显示的模型映射预设为合并结果——请确保映射对每个平台都适用。'
},
bulkDeleteTitle: '批量删除账号',