From 827764d7bdbb1b2d4e554088b618d3293aa3a3f3 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 14 May 2026 15:00:28 +0800 Subject: [PATCH] fix(account): preserve combined model restrictions --- .../components/account/EditAccountModal.vue | 112 ++++-------------- .../__tests__/EditAccountModal.spec.ts | 25 ++++ .../__tests__/useModelWhitelist.spec.ts | 32 ++++- frontend/src/composables/useModelWhitelist.ts | 49 +++++++- 4 files changed, 125 insertions(+), 93 deletions(-) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 80f0b890..8e3c32b1 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') }}

@@ -2208,6 +2208,7 @@ import { getPresetMappingsByPlatform, commonErrorCodes, buildModelMappingObject, + splitModelMappingObject, isValidWildcardPattern } from '@/composables/useModelWhitelist' @@ -2542,6 +2543,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 @@ -2713,30 +2727,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 @@ -2780,24 +2771,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) || '' @@ -2808,24 +2782,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' @@ -2838,24 +2795,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 = [] @@ -3356,7 +3296,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 { @@ -3444,7 +3384,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 { @@ -3494,7 +3434,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 { @@ -3528,7 +3468,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 04486154..1e915c10 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()