fix(account): preserve combined model restrictions
This commit is contained in:
parent
18790386a7
commit
827764d7bd
@ -142,7 +142,7 @@
|
|||||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||||
<span v-if="allowedModels.length === 0">{{
|
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
|
||||||
t('admin.accounts.supportsAllModels')
|
t('admin.accounts.supportsAllModels')
|
||||||
}}</span>
|
}}</span>
|
||||||
</p>
|
</p>
|
||||||
@ -457,7 +457,7 @@
|
|||||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||||
<span v-if="allowedModels.length === 0">{{
|
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
|
||||||
t('admin.accounts.supportsAllModels')
|
t('admin.accounts.supportsAllModels')
|
||||||
}}</span>
|
}}</span>
|
||||||
</p>
|
</p>
|
||||||
@ -669,7 +669,7 @@
|
|||||||
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||||
<span v-if="allowedModels.length === 0">{{
|
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{
|
||||||
t('admin.accounts.supportsAllModels')
|
t('admin.accounts.supportsAllModels')
|
||||||
}}</span>
|
}}</span>
|
||||||
</p>
|
</p>
|
||||||
@ -891,7 +891,7 @@
|
|||||||
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" />
|
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" />
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||||
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
|
<span v-if="allowedModels.length === 0 && modelMappings.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -2208,6 +2208,7 @@ import {
|
|||||||
getPresetMappingsByPlatform,
|
getPresetMappingsByPlatform,
|
||||||
commonErrorCodes,
|
commonErrorCodes,
|
||||||
buildModelMappingObject,
|
buildModelMappingObject,
|
||||||
|
splitModelMappingObject,
|
||||||
isValidWildcardPattern
|
isValidWildcardPattern
|
||||||
} from '@/composables/useModelWhitelist'
|
} from '@/composables/useModelWhitelist'
|
||||||
|
|
||||||
@ -2542,6 +2543,19 @@ const normalizePoolModeRetryCount = (value: number) => {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadModelRestrictionFromMapping = (rawMapping?: Record<string, unknown>) => {
|
||||||
|
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) => {
|
const syncFormFromAccount = (newAccount: Account | null) => {
|
||||||
if (!newAccount) {
|
if (!newAccount) {
|
||||||
return
|
return
|
||||||
@ -2713,30 +2727,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
|
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
|
||||||
|
|
||||||
// Load model mappings and detect mode
|
// Load model mappings and detect mode
|
||||||
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
|
loadModelRestrictionFromMapping(credentials.model_mapping as Record<string, unknown> | 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 = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load pool mode
|
// Load pool mode
|
||||||
poolModeEnabled.value = credentials.pool_mode === true
|
poolModeEnabled.value = credentials.pool_mode === true
|
||||||
@ -2780,24 +2771,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
loadQuotaNotifyFromExtra(bedrockExtra)
|
loadQuotaNotifyFromExtra(bedrockExtra)
|
||||||
|
|
||||||
// Load model mappings for bedrock
|
// Load model mappings for bedrock
|
||||||
const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined
|
loadModelRestrictionFromMapping(bedrockCreds.model_mapping as Record<string, unknown> | 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 = []
|
|
||||||
}
|
|
||||||
} else if (newAccount.type === 'upstream' && newAccount.credentials) {
|
} else if (newAccount.type === 'upstream' && newAccount.credentials) {
|
||||||
const credentials = newAccount.credentials as Record<string, unknown>
|
const credentials = newAccount.credentials as Record<string, unknown>
|
||||||
editBaseUrl.value = (credentials.base_url as string) || ''
|
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'
|
editVertexLocation.value = (credentials.location as string) || (credentials.vertex_location as string) || 'us-central1'
|
||||||
|
|
||||||
// Load model mappings for service_account
|
// Load model mappings for service_account
|
||||||
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
|
loadModelRestrictionFromMapping(credentials.model_mapping as Record<string, unknown> | 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 = []
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const platformDefaultUrl =
|
const platformDefaultUrl =
|
||||||
newAccount.platform === 'openai'
|
newAccount.platform === 'openai'
|
||||||
@ -2838,24 +2795,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
// Load model mappings for OpenAI OAuth accounts
|
// Load model mappings for OpenAI OAuth accounts
|
||||||
if (newAccount.platform === 'openai' && newAccount.credentials) {
|
if (newAccount.platform === 'openai' && newAccount.credentials) {
|
||||||
const oauthCredentials = newAccount.credentials as Record<string, unknown>
|
const oauthCredentials = newAccount.credentials as Record<string, unknown>
|
||||||
const existingMappings = oauthCredentials.model_mapping as Record<string, string> | undefined
|
loadModelRestrictionFromMapping(oauthCredentials.model_mapping as Record<string, unknown> | 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 = []
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
modelRestrictionMode.value = 'whitelist'
|
modelRestrictionMode.value = 'whitelist'
|
||||||
modelMappings.value = []
|
modelMappings.value = []
|
||||||
@ -3356,7 +3296,7 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
// Add model mapping if configured(OpenAI 开启自动透传时保留现有映射,不再编辑)
|
// Add model mapping if configured(OpenAI 开启自动透传时保留现有映射,不再编辑)
|
||||||
if (shouldApplyModelMapping) {
|
if (shouldApplyModelMapping) {
|
||||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
const modelMapping = buildModelRestrictionMapping()
|
||||||
if (modelMapping) {
|
if (modelMapping) {
|
||||||
newCredentials.model_mapping = modelMapping
|
newCredentials.model_mapping = modelMapping
|
||||||
} else {
|
} else {
|
||||||
@ -3444,7 +3384,7 @@ const handleSubmit = async () => {
|
|||||||
newCredentials.tier_id = 'vertex'
|
newCredentials.tier_id = 'vertex'
|
||||||
|
|
||||||
// Add model mapping if configured
|
// Add model mapping if configured
|
||||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
const modelMapping = buildModelRestrictionMapping()
|
||||||
if (modelMapping) {
|
if (modelMapping) {
|
||||||
newCredentials.model_mapping = modelMapping
|
newCredentials.model_mapping = modelMapping
|
||||||
} else {
|
} else {
|
||||||
@ -3494,7 +3434,7 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Model mapping
|
// Model mapping
|
||||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
const modelMapping = buildModelRestrictionMapping()
|
||||||
if (modelMapping) {
|
if (modelMapping) {
|
||||||
newCredentials.model_mapping = modelMapping
|
newCredentials.model_mapping = modelMapping
|
||||||
} else {
|
} else {
|
||||||
@ -3528,7 +3468,7 @@ const handleSubmit = async () => {
|
|||||||
const shouldApplyModelMapping = !openaiPassthroughEnabled.value
|
const shouldApplyModelMapping = !openaiPassthroughEnabled.value
|
||||||
|
|
||||||
if (shouldApplyModelMapping) {
|
if (shouldApplyModelMapping) {
|
||||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
const modelMapping = buildModelRestrictionMapping()
|
||||||
if (modelMapping) {
|
if (modelMapping) {
|
||||||
newCredentials.model_mapping = modelMapping
|
newCredentials.model_mapping = modelMapping
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -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 () => {
|
it('submits OpenAI compact mode and compact-only model mapping', async () => {
|
||||||
const account = buildAccount()
|
const account = buildAccount()
|
||||||
account.extra = {
|
account.extra = {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ vi.mock('@/api/admin/accounts', () => ({
|
|||||||
getAntigravityDefaultModelMapping: vi.fn()
|
getAntigravityDefaultModelMapping: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { buildModelMappingObject, getModelsByPlatform } from '../useModelWhitelist'
|
import { buildModelMappingObject, getModelsByPlatform, splitModelMappingObject } from '../useModelWhitelist'
|
||||||
|
|
||||||
describe('useModelWhitelist', () => {
|
describe('useModelWhitelist', () => {
|
||||||
it('openai 模型列表包含 GPT-5.4 官方快照', () => {
|
it('openai 模型列表包含 GPT-5.4 官方快照', () => {
|
||||||
@ -73,4 +73,34 @@ describe('useModelWhitelist', () => {
|
|||||||
'gpt-5.4-mini': 'gpt-5.4-mini'
|
'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' }]
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -395,23 +395,60 @@ export function isValidWildcardPattern(pattern: string): boolean {
|
|||||||
return starIndex === pattern.length - 1 && pattern.lastIndexOf('*') === starIndex
|
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<string, unknown> | 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(
|
export function buildModelMappingObject(
|
||||||
mode: 'whitelist' | 'mapping',
|
mode: ModelRestrictionMode,
|
||||||
allowedModels: string[],
|
allowedModels: string[],
|
||||||
modelMappings: { from: string; to: string }[]
|
modelMappings: ModelMappingEntry[]
|
||||||
): Record<string, string> | null {
|
): Record<string, string> | null {
|
||||||
const mapping: Record<string, string> = {}
|
const mapping: Record<string, string> = {}
|
||||||
|
|
||||||
if (mode === 'whitelist') {
|
if (mode === 'whitelist' || mode === 'combined') {
|
||||||
for (const model of allowedModels) {
|
for (const model of allowedModels) {
|
||||||
|
const normalizedModel = model.trim()
|
||||||
|
if (!normalizedModel) continue
|
||||||
// whitelist 模式的本意是"精确模型列表",如果用户输入了通配符(如 claude-*),
|
// whitelist 模式的本意是"精确模型列表",如果用户输入了通配符(如 claude-*),
|
||||||
// 写入 model_mapping 会导致 GetMappedModel() 把真实模型映射成 "claude-*",从而转发失败。
|
// 写入 model_mapping 会导致 GetMappedModel() 把真实模型映射成 "claude-*",从而转发失败。
|
||||||
// 因此这里跳过包含通配符的条目。
|
// 因此这里跳过包含通配符的条目。
|
||||||
if (!model.includes('*')) {
|
if (!normalizedModel.includes('*')) {
|
||||||
mapping[model] = model
|
mapping[normalizedModel] = normalizedModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (mode === 'mapping' || mode === 'combined') {
|
||||||
for (const m of modelMappings) {
|
for (const m of modelMappings) {
|
||||||
const from = m.from.trim()
|
const from = m.from.trim()
|
||||||
const to = m.to.trim()
|
const to = m.to.trim()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user