diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 70582c6f..7adb7378 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -142,7 +142,7 @@
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
- {{
+ {{
t('admin.accounts.supportsAllModels')
}}
@@ -457,7 +457,7 @@
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
- {{
+ {{
t('admin.accounts.supportsAllModels')
}}
@@ -669,7 +669,7 @@
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
- {{
+ {{
t('admin.accounts.supportsAllModels')
}}
@@ -891,7 +891,7 @@
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
- {{ t('admin.accounts.supportsAllModels') }}
+ {{ t('admin.accounts.supportsAllModels') }}
@@ -2244,6 +2244,7 @@ import {
getPresetMappingsByPlatform,
commonErrorCodes,
buildModelMappingObject,
+ splitModelMappingObject,
isValidWildcardPattern
} from '@/composables/useModelWhitelist'
@@ -2607,6 +2608,19 @@ const normalizePoolModeRetryCount = (value: number) => {
return normalized
}
+const loadModelRestrictionFromMapping = (rawMapping?: Record) => {
+ const parsed = splitModelMappingObject(rawMapping)
+ allowedModels.value = parsed.allowedModels
+ modelMappings.value = parsed.modelMappings
+ modelRestrictionMode.value =
+ parsed.modelMappings.length > 0 && parsed.allowedModels.length === 0
+ ? 'mapping'
+ : 'whitelist'
+}
+
+const buildModelRestrictionMapping = () =>
+ buildModelMappingObject('combined', allowedModels.value, modelMappings.value)
+
const syncFormFromAccount = (newAccount: Account | null) => {
if (!newAccount) {
return
@@ -2782,30 +2796,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
// Load model mappings and detect mode
- const existingMappings = credentials.model_mapping as Record | undefined
- if (existingMappings && typeof existingMappings === 'object') {
- const entries = Object.entries(existingMappings)
-
- // Detect if this is whitelist mode (all from === to) or mapping mode
- const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
-
- if (isWhitelistMode) {
- // Whitelist mode: populate allowedModels
- modelRestrictionMode.value = 'whitelist'
- allowedModels.value = entries.map(([from]) => from)
- modelMappings.value = []
- } else {
- // Mapping mode: populate modelMappings
- modelRestrictionMode.value = 'mapping'
- modelMappings.value = entries.map(([from, to]) => ({ from, to }))
- allowedModels.value = []
- }
- } else {
- // No mappings: default to whitelist mode with empty selection (allow all)
- modelRestrictionMode.value = 'whitelist'
- modelMappings.value = []
- allowedModels.value = []
- }
+ loadModelRestrictionFromMapping(credentials.model_mapping as Record | undefined)
// Load pool mode
poolModeEnabled.value = credentials.pool_mode === true
@@ -2849,24 +2840,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
loadQuotaNotifyFromExtra(bedrockExtra)
// Load model mappings for bedrock
- const existingMappings = bedrockCreds.model_mapping as Record | undefined
- if (existingMappings && typeof existingMappings === 'object') {
- const entries = Object.entries(existingMappings)
- const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
- if (isWhitelistMode) {
- modelRestrictionMode.value = 'whitelist'
- allowedModels.value = entries.map(([from]) => from)
- modelMappings.value = []
- } else {
- modelRestrictionMode.value = 'mapping'
- modelMappings.value = entries.map(([from, to]) => ({ from, to }))
- allowedModels.value = []
- }
- } else {
- modelRestrictionMode.value = 'whitelist'
- modelMappings.value = []
- allowedModels.value = []
- }
+ loadModelRestrictionFromMapping(bedrockCreds.model_mapping as Record | undefined)
} else if (newAccount.type === 'upstream' && newAccount.credentials) {
const credentials = newAccount.credentials as Record
editBaseUrl.value = (credentials.base_url as string) || ''
@@ -2877,24 +2851,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editVertexLocation.value = (credentials.location as string) || (credentials.vertex_location as string) || 'us-central1'
// Load model mappings for service_account
- const existingMappings = credentials.model_mapping as Record | undefined
- if (existingMappings && typeof existingMappings === 'object') {
- const entries = Object.entries(existingMappings)
- const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
- if (isWhitelistMode) {
- modelRestrictionMode.value = 'whitelist'
- allowedModels.value = entries.map(([from]) => from)
- modelMappings.value = []
- } else {
- modelRestrictionMode.value = 'mapping'
- modelMappings.value = entries.map(([from, to]) => ({ from, to }))
- allowedModels.value = []
- }
- } else {
- modelRestrictionMode.value = 'whitelist'
- modelMappings.value = []
- allowedModels.value = []
- }
+ loadModelRestrictionFromMapping(credentials.model_mapping as Record | undefined)
} else {
const platformDefaultUrl =
newAccount.platform === 'openai'
@@ -2907,24 +2864,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
// Load model mappings for OpenAI OAuth accounts
if (newAccount.platform === 'openai' && newAccount.credentials) {
const oauthCredentials = newAccount.credentials as Record
- const existingMappings = oauthCredentials.model_mapping as Record | undefined
- if (existingMappings && typeof existingMappings === 'object') {
- const entries = Object.entries(existingMappings)
- const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
- if (isWhitelistMode) {
- modelRestrictionMode.value = 'whitelist'
- allowedModels.value = entries.map(([from]) => from)
- modelMappings.value = []
- } else {
- modelRestrictionMode.value = 'mapping'
- modelMappings.value = entries.map(([from, to]) => ({ from, to }))
- allowedModels.value = []
- }
- } else {
- modelRestrictionMode.value = 'whitelist'
- modelMappings.value = []
- allowedModels.value = []
- }
+ loadModelRestrictionFromMapping(oauthCredentials.model_mapping as Record | undefined)
} else {
modelRestrictionMode.value = 'whitelist'
modelMappings.value = []
@@ -3459,7 +3399,7 @@ const handleSubmit = async () => {
// Add model mapping if configured(OpenAI 开启自动透传时保留现有映射,不再编辑)
if (shouldApplyModelMapping) {
- const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
+ const modelMapping = buildModelRestrictionMapping()
if (modelMapping) {
newCredentials.model_mapping = modelMapping
} else {
@@ -3547,7 +3487,7 @@ const handleSubmit = async () => {
newCredentials.tier_id = 'vertex'
// Add model mapping if configured
- const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
+ const modelMapping = buildModelRestrictionMapping()
if (modelMapping) {
newCredentials.model_mapping = modelMapping
} else {
@@ -3597,7 +3537,7 @@ const handleSubmit = async () => {
}
// Model mapping
- const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
+ const modelMapping = buildModelRestrictionMapping()
if (modelMapping) {
newCredentials.model_mapping = modelMapping
} else {
@@ -3631,7 +3571,7 @@ const handleSubmit = async () => {
const shouldApplyModelMapping = !openaiPassthroughEnabled.value
if (shouldApplyModelMapping) {
- const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
+ const modelMapping = buildModelRestrictionMapping()
if (modelMapping) {
newCredentials.model_mapping = modelMapping
} else {
diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
index edede13d..fa121ab5 100644
--- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
@@ -190,6 +190,31 @@ describe('EditAccountModal', () => {
})
})
+ it('preserves model mappings when editing the whitelist', async () => {
+ const account = buildAccount()
+ account.credentials.model_mapping = {
+ 'gpt-5.2': 'gpt-5.2',
+ 'gpt-latest': 'gpt-5.2'
+ }
+ updateAccountMock.mockReset()
+ checkMixedChannelRiskMock.mockReset()
+ checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
+ updateAccountMock.mockResolvedValue(account)
+
+ const wrapper = mountModal(account)
+
+ expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2')
+
+ await wrapper.get('[data-testid="rewrite-to-snapshot"]').trigger('click')
+ await wrapper.get('form#edit-account-form').trigger('submit.prevent')
+
+ expect(updateAccountMock).toHaveBeenCalledTimes(1)
+ expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual({
+ 'gpt-5.2-2025-12-11': 'gpt-5.2-2025-12-11',
+ 'gpt-latest': 'gpt-5.2'
+ })
+ })
+
it('submits OpenAI compact mode and compact-only model mapping', async () => {
const account = buildAccount()
account.extra = {
diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts
index 29ec513e..f2e19dc1 100644
--- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts
+++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts
@@ -4,7 +4,7 @@ vi.mock('@/api/admin/accounts', () => ({
getAntigravityDefaultModelMapping: vi.fn()
}))
-import { buildModelMappingObject, getModelsByPlatform } from '../useModelWhitelist'
+import { buildModelMappingObject, getModelsByPlatform, splitModelMappingObject } from '../useModelWhitelist'
describe('useModelWhitelist', () => {
it('openai 模型列表包含 GPT-5.4 官方快照', () => {
@@ -73,4 +73,34 @@ describe('useModelWhitelist', () => {
'gpt-5.4-mini': 'gpt-5.4-mini'
})
})
+
+ it('combined 模式会同时保留白名单身份映射和模型映射', () => {
+ const mapping = buildModelMappingObject(
+ 'combined',
+ ['gpt-5.4', 'claude-*'],
+ [
+ { from: 'gpt-latest', to: 'gpt-5.4' },
+ { from: 'gpt-5.4', to: 'gpt-5.4-mini' }
+ ]
+ )
+
+ expect(mapping).toEqual({
+ 'gpt-5.4': 'gpt-5.4-mini',
+ 'gpt-latest': 'gpt-5.4'
+ })
+ })
+
+ it('splitModelMappingObject 会把身份映射还原成白名单,其余保留为映射', () => {
+ const parsed = splitModelMappingObject({
+ 'gpt-5.4': 'gpt-5.4',
+ 'gpt-latest': 'gpt-5.4',
+ ' ': 'gpt-empty',
+ broken: 123
+ })
+
+ expect(parsed).toEqual({
+ allowedModels: ['gpt-5.4'],
+ modelMappings: [{ from: 'gpt-latest', to: 'gpt-5.4' }]
+ })
+ })
})
diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts
index 4d9b7fe2..ad2dc103 100644
--- a/frontend/src/composables/useModelWhitelist.ts
+++ b/frontend/src/composables/useModelWhitelist.ts
@@ -395,23 +395,60 @@ export function isValidWildcardPattern(pattern: string): boolean {
return starIndex === pattern.length - 1 && pattern.lastIndexOf('*') === starIndex
}
+export type ModelRestrictionMode = 'whitelist' | 'mapping' | 'combined'
+
+export interface ModelMappingEntry {
+ from: string
+ to: string
+}
+
+export function splitModelMappingObject(
+ modelMapping?: Record | null
+): { allowedModels: string[]; modelMappings: ModelMappingEntry[] } {
+ const allowedModels: string[] = []
+ const modelMappings: ModelMappingEntry[] = []
+
+ if (!modelMapping || typeof modelMapping !== 'object') {
+ return { allowedModels, modelMappings }
+ }
+
+ for (const [rawFrom, rawTo] of Object.entries(modelMapping)) {
+ if (typeof rawTo !== 'string') continue
+ const from = rawFrom.trim()
+ const to = rawTo.trim()
+ if (!from || !to) continue
+
+ if (from === to) {
+ allowedModels.push(from)
+ } else {
+ modelMappings.push({ from, to })
+ }
+ }
+
+ return { allowedModels, modelMappings }
+}
+
export function buildModelMappingObject(
- mode: 'whitelist' | 'mapping',
+ mode: ModelRestrictionMode,
allowedModels: string[],
- modelMappings: { from: string; to: string }[]
+ modelMappings: ModelMappingEntry[]
): Record | null {
const mapping: Record = {}
- if (mode === 'whitelist') {
+ if (mode === 'whitelist' || mode === 'combined') {
for (const model of allowedModels) {
+ const normalizedModel = model.trim()
+ if (!normalizedModel) continue
// whitelist 模式的本意是"精确模型列表",如果用户输入了通配符(如 claude-*),
// 写入 model_mapping 会导致 GetMappedModel() 把真实模型映射成 "claude-*",从而转发失败。
// 因此这里跳过包含通配符的条目。
- if (!model.includes('*')) {
- mapping[model] = model
+ if (!normalizedModel.includes('*')) {
+ mapping[normalizedModel] = normalizedModel
}
}
- } else {
+ }
+
+ if (mode === 'mapping' || mode === 'combined') {
for (const m of modelMappings) {
const from = m.from.trim()
const to = m.to.trim()