mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(account): prevent whitelist model mapping conflicts
This commit is contained in:
@@ -343,6 +343,7 @@
|
||||
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
:model-mappings="modelMappings"
|
||||
:platforms="targetSelectedPlatforms"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1518,6 +1518,7 @@
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
:model-mappings="modelMappings"
|
||||
:platform="form.platform"
|
||||
:sync-credentials="syncPreviewCredentials"
|
||||
@upstream-synced="upstreamModelsPreviewed = true"
|
||||
@@ -2005,6 +2006,7 @@
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
:model-mappings="modelMappings"
|
||||
platform="anthropic"
|
||||
:sync-credentials="syncPreviewCredentials"
|
||||
@upstream-synced="upstreamModelsPreviewed = true"
|
||||
@@ -2346,6 +2348,7 @@
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
:model-mappings="modelMappings"
|
||||
:platform="form.platform"
|
||||
:sync-credentials="syncPreviewCredentials"
|
||||
@upstream-synced="upstreamModelsPreviewed = true"
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
|
||||
<ModelWhitelistSelector v-model="allowedModels" :model-mappings="modelMappings" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
|
||||
@@ -779,7 +779,7 @@
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
|
||||
<ModelWhitelistSelector v-model="allowedModels" :model-mappings="modelMappings" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
|
||||
@@ -991,7 +991,7 @@
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
|
||||
<ModelWhitelistSelector v-model="allowedModels" :model-mappings="modelMappings" :platform="account?.platform || 'anthropic'" :account-id="account?.id" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
|
||||
@@ -1213,7 +1213,7 @@
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" />
|
||||
<ModelWhitelistSelector v-model="allowedModels" :model-mappings="modelMappings" platform="anthropic" />
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
|
||||
|
||||
@@ -159,6 +159,7 @@ const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
modelMappings?: { from: string; to: string }[]
|
||||
platform?: string
|
||||
platforms?: string[]
|
||||
accountId?: number
|
||||
@@ -274,6 +275,11 @@ const addCustom = () => {
|
||||
appStore.showInfo(t('admin.accounts.modelExists'))
|
||||
return
|
||||
}
|
||||
const conflict = props.modelMappings?.find(mapping => mapping.from.trim() === model && mapping.to.trim() && mapping.to.trim() !== model)
|
||||
if (conflict) {
|
||||
appStore.showInfo(t('admin.accounts.modelMappingConflict', { from: model, to: conflict.to.trim() }))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', [...props.modelValue, model])
|
||||
customModel.value = ''
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const BaseDialogStub = defineComponent({
|
||||
const ModelWhitelistSelectorStub = defineComponent({
|
||||
name: 'ModelWhitelistSelector',
|
||||
props: {
|
||||
modelMappings: { type: Array, default: () => [] },
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
@@ -330,6 +331,24 @@ describe('EditAccountModal', () => {
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('passes existing non-identity mappings to the whitelist selector and preserves them on save', async () => {
|
||||
const account = buildAccount()
|
||||
account.credentials.model_mapping = { 'gpt-5.2': 'gpt-5.2', 'gpt-latest': 'deepseek-chat' }
|
||||
updateAccountMock.mockReset().mockResolvedValue(account)
|
||||
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
|
||||
const wrapper = mountModal(account)
|
||||
expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('modelMappings')).toEqual([
|
||||
{ from: 'gpt-latest', to: 'deepseek-chat' }
|
||||
])
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual(account.credentials.model_mapping)
|
||||
await wrapper.setProps({ show: false })
|
||||
await wrapper.setProps({ show: true, account: { ...account } })
|
||||
expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('modelMappings')).toEqual([
|
||||
{ from: 'gpt-latest', to: 'deepseek-chat' }
|
||||
])
|
||||
})
|
||||
|
||||
it('sets expiry presets from now instead of extending the saved expiry', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2028-02-29T12:34:00'))
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock('vue-i18n', async () => {
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => (key === 'common.copy' ? '复制' : key)
|
||||
t: (key: string, params?: Record<string, string>) => key === 'common.copy' ? '复制' : key === 'admin.accounts.modelMappingConflict' ? `Model mapping conflict: ${params?.from} → ${params?.to}` : key
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -91,6 +91,37 @@ describe('ModelWhitelistSelector', () => {
|
||||
syncUpstreamModelsPreview.mockReset()
|
||||
})
|
||||
|
||||
it('rejects a custom whitelist model that is already mapped to a different target', async () => {
|
||||
const wrapper = mountSelector({ modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] })
|
||||
await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue(' gpt-latest ')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(showInfo).toHaveBeenCalledWith(expect.stringContaining('gpt-latest → deepseek-chat'))
|
||||
})
|
||||
|
||||
it('keeps the existing duplicate identity warning before checking mappings', async () => {
|
||||
const wrapper = mountSelector({ modelValue: ['gpt-latest'], modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] })
|
||||
await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('gpt-latest')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click')
|
||||
expect(showInfo).toHaveBeenCalledWith('admin.accounts.modelExists')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('allows matching identity mapping as a whitelist model', async () => {
|
||||
const wrapper = mountSelector({ modelMappings: [{ from: 'gpt-latest', to: 'gpt-latest' }] })
|
||||
await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('gpt-latest')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-latest']]])
|
||||
})
|
||||
|
||||
it('still allows custom models without a mapping prop', async () => {
|
||||
const wrapper = mountSelector()
|
||||
await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('custom-model')
|
||||
await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[['custom-model']]])
|
||||
})
|
||||
|
||||
it('copies a model ID without selecting the model', async () => {
|
||||
const wrapper = mountSelector()
|
||||
await wrapper.get('div.cursor-pointer').trigger('click')
|
||||
|
||||
@@ -158,6 +158,19 @@ describe('useModelWhitelist', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('combined mode retains a mapping when a whitelist entry has the same source', () => {
|
||||
expect(buildModelMappingObject('combined', ['gpt-latest'], [{ from: 'gpt-latest', to: 'deepseek-chat' }])).toEqual({
|
||||
'gpt-latest': 'deepseek-chat'
|
||||
})
|
||||
})
|
||||
|
||||
it('split mapping keeps only identity entries in the whitelist after reopening', () => {
|
||||
expect(splitModelMappingObject({ 'gpt-latest': 'deepseek-chat', 'gpt-5.4': 'gpt-5.4' })).toEqual({
|
||||
allowedModels: ['gpt-5.4'],
|
||||
modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('splitModelMappingObject 会把身份映射还原成白名单,其余保留为映射', () => {
|
||||
const parsed = splitModelMappingObject({
|
||||
'gpt-5.4': 'gpt-5.4',
|
||||
|
||||
@@ -828,6 +828,7 @@ export default {
|
||||
enterCustomModelName: 'Enter custom model name',
|
||||
addModel: 'Add',
|
||||
modelExists: 'Model already exists',
|
||||
modelMappingConflict: 'A mapping already exists for {from} → {to}. Modify or remove it under Model Mapping before adding this whitelist model',
|
||||
modelCount: '{count} models',
|
||||
poolMode: 'Pool Mode',
|
||||
poolModeHint: 'Enable when upstream is an account pool; errors won\'t mark local account status',
|
||||
|
||||
@@ -933,6 +933,7 @@ export default {
|
||||
enterCustomModelName: '输入自定义模型名称',
|
||||
addModel: '填入',
|
||||
modelExists: '该模型已存在',
|
||||
modelMappingConflict: '该模型已配置映射 {from} → {to},请在模型映射中修改或删除后再添加白名单模型',
|
||||
modelCount: '{count} 个模型',
|
||||
poolMode: '池模式',
|
||||
poolModeHint: '上游为账号池时启用,错误不标记本地账号状态',
|
||||
|
||||
Reference in New Issue
Block a user