fix(frontend): 按平台能力展示模型同步入口并同步 Zen 规则

This commit is contained in:
SavitarC
2026-10-09 10:11:06 +08:00
parent 9264414612
commit 7ca08d521f
9 changed files with 254 additions and 17 deletions
@@ -154,7 +154,7 @@ import { useClipboard } from '@/composables/useClipboard'
import ModelIcon from '@/components/common/ModelIcon.vue'
import Icon from '@/components/icons/Icon.vue'
import { allModels, getModelsByPlatform } from '@/composables/useModelWhitelist'
import { isKnownPlatform } from '@/constants/platformCatalog'
import { supportsUpstreamModelSync } from '@/constants/platformCatalog'
const { t } = useI18n()
@@ -202,15 +202,13 @@ const normalizedPlatforms = computed(() => {
)
})
// 上游模型同步支持全部已登记的具体平台(多协议供应商复用 OpenAI /v1/models)。
const canSyncUpstreamPlatform = (platform: string) => isKnownPlatform(platform.toLowerCase())
const canSyncUpstream = computed(() => {
if (props.accountId) {
if (normalizedPlatforms.value.length === 0) return true
return normalizedPlatforms.value.some(canSyncUpstreamPlatform)
return normalizedPlatforms.value.some(supportsUpstreamModelSync)
}
if (props.syncCredentials) {
return canSyncUpstreamPlatform(props.syncCredentials.platform)
return supportsUpstreamModelSync(props.syncCredentials.platform)
}
return false
})
@@ -292,8 +290,7 @@ const fillRelated = () => {
}
const syncUpstreamModels = async () => {
if (isSyncingUpstream.value) return
if (!props.accountId && !props.syncCredentials) return
if (isSyncingUpstream.value || !canSyncUpstream.value) return
isSyncingUpstream.value = true
try {
@@ -474,6 +474,7 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
{ pattern: 'gpt-*', protocol: 'responses' },
{ pattern: 'muse-spark-*', protocol: 'responses' },
{ pattern: 'claude-*', protocol: 'anthropic' },
{ pattern: 'qwen3.8-max', protocol: 'chat_completions' },
{ pattern: 'qwen*', protocol: 'anthropic' }
]
})
@@ -1,5 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import {
BUILTIN_PLATFORM_CATALOG,
resetPlatformCatalog,
setPlatformCatalog
} from '@/constants/platformCatalog'
const {
copyToClipboard,
@@ -91,6 +96,61 @@ describe('ModelWhitelistSelector', () => {
syncUpstreamModelsPreview.mockReset()
})
afterEach(() => {
resetPlatformCatalog()
})
it.each(['anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'command_code', 'cline'])(
'supports upstream sync for %s saved accounts and creation previews',
(platform) => {
const wrappers = [
mountSelector({ platform, accountId: 46 }),
mountSelector({ platform, syncCredentials: { platform, type: 'apikey', api_key: 'test-key' } })
]
for (const wrapper of wrappers) {
expect(wrapper.findAll('button').some(button => button.text() === 'admin.accounts.syncUpstreamModels')).toBe(true)
wrapper.unmount()
}
}
)
it.each(['typesafe', 'unregistered'])(
'hides upstream sync for unsupported %s saved accounts and creation previews',
(platform) => {
const wrappers = [
mountSelector({ platform, accountId: 46 }),
mountSelector({ platform, syncCredentials: { platform, type: 'apikey', api_key: 'test-key' } })
]
for (const wrapper of wrappers) {
expect(wrapper.findAll('button').some(button => button.text() === 'admin.accounts.syncUpstreamModels')).toBe(false)
wrapper.unmount()
}
expect(syncUpstreamModels).not.toHaveBeenCalled()
expect(syncUpstreamModelsPreview).not.toHaveBeenCalled()
}
)
it('requires a supported request builder for newly registered platforms', async () => {
const wrapper = mountSelector({ platform: 'acme_router', accountId: 46 })
const platforms = [
...BUILTIN_PLATFORM_CATALOG.platforms,
{ id: 'acme_router', display_name: 'Acme Router', gateway: 'openai' as const, cn_provider: false }
]
setPlatformCatalog({ ...BUILTIN_PLATFORM_CATALOG, platforms })
await flushPromises()
expect(wrapper.findAll('button').some(button => button.text() === 'admin.accounts.syncUpstreamModels')).toBe(false)
setPlatformCatalog({
...BUILTIN_PLATFORM_CATALOG,
platforms: platforms.map(spec => spec.id === 'acme_router'
? { ...spec, multi_protocol: { default_mode: 'default', routing: 'by_inbound', modes: [] } }
: spec)
})
await flushPromises()
expect(wrapper.findAll('button').some(button => button.text() === 'admin.accounts.syncUpstreamModels')).toBe(true)
wrapper.unmount()
})
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 ')
@@ -53,6 +53,7 @@ const legacyZenRules = [
{ pattern: 'gpt-*', protocol: 'responses' },
{ pattern: 'muse-spark-*', protocol: 'responses' },
{ pattern: 'claude-*', protocol: 'anthropic' },
{ pattern: 'qwen3.8-max', protocol: 'chat_completions' },
{ pattern: 'qwen*', protocol: 'anthropic' }
]
@@ -415,7 +415,8 @@ describe('UseKeyModal', () => {
expect(configToml).not.toContain('image_generation')
expect(configToml).not.toContain('supports_websockets')
expect(configToml).not.toContain('responses_websockets_v2')
expect(configToml).toContain('[features]\ngoals = true')
const features = configToml?.split('[features]\n')[1]?.split('\n[')[0]
expect(features).toContain('goals = true')
expect(configToml).not.toContain('model_reasoning_effort = "xhigh"')
expect(codeBlocks).toContain('{\n "OPENAI_API_KEY": "sk-test"\n}')
expect(wrapper.text()).toContain('auth.json')
@@ -557,7 +558,9 @@ describe('UseKeyModal', () => {
expect(configToml).not.toContain('env_key')
expect(configToml).not.toContain('image_generation')
expect(configToml).toContain('supports_websockets = true')
expect(configToml).toContain('[features]\nresponses_websockets_v2 = true\ngoals = true')
const features = configToml?.split('[features]\n')[1]?.split('\n[')[0]
expect(features).toContain('responses_websockets_v2 = true')
expect(features).toContain('goals = true')
expect(codeBlocks).toContain('{\n "OPENAI_API_KEY": "sk-test"\n}')
expect(wrapper.text()).toContain('auth.json')
})
@@ -605,7 +608,9 @@ describe('UseKeyModal', () => {
expect(configToml).not.toContain('env_key')
expect(configToml).not.toContain('image_generation')
expect(configToml).toContain('supports_websockets = true')
expect(configToml).toContain('[features]\nresponses_websockets_v2 = true\ngoals = true')
const features = configToml?.split('[features]\n')[1]?.split('\n[')[0]
expect(features).toContain('responses_websockets_v2 = true')
expect(features).toContain('goals = true')
expect(codeBlocks).not.toContain('{\n "OPENAI_API_KEY": "sk-test"\n}')
expect(wrapper.text()).not.toContain('auth.json')
})
@@ -206,6 +206,10 @@
"pattern": "claude-*",
"protocol": "anthropic"
},
{
"pattern": "qwen3.8-max",
"protocol": "chat_completions"
},
{
"pattern": "qwen*",
"protocol": "anthropic"
+12
View File
@@ -85,6 +85,18 @@ export function isKnownPlatform(platform: string | null | undefined): boolean {
return !!getPlatformSpec(platform)
}
/** 与后端 buildUpstreamModelsRequest 支持的平台保持一致。 */
export function supportsUpstreamModelSync(platform: string | null | undefined): boolean {
const spec = getPlatformSpec(platform?.trim().toLowerCase())
if (!spec) return false
return !!spec.multi_protocol || ['anthropic', 'openai', 'gemini', 'antigravity', 'grok'].includes(spec.id)
}
/** 清单中声明了定价目录 provider 的平台才支持同步定价模型。 */
export function supportsPricingModelSync(platform: string | null | undefined): boolean {
return !!getPlatformSpec(platform?.trim().toLowerCase())?.litellm_provider?.trim()
}
/** 平台展示名;未登记的值原样返回。 */
export function platformDisplayName(platform: string | null | undefined): string {
return getPlatformSpec(platform)?.display_name ?? platform ?? ''
+8 -6
View File
@@ -423,9 +423,10 @@
<label class="input-label text-xs mb-0">{{ t('admin.channels.form.modelPricing', 'Model Pricing') }}</label>
<div class="flex items-center gap-2">
<button
v-if="supportsPricingModelSync(section.platform)"
type="button"
@click="syncLatestModels(sIdx)"
:disabled="syncingPlatform === section.platform"
:disabled="!!syncingPlatform"
class="text-xs text-gray-500 hover:text-primary-600 disabled:opacity-50"
>
{{ syncingPlatform === section.platform ? t('admin.channels.form.syncingModels') : t('admin.channels.form.syncLatestModels') }}
@@ -638,7 +639,7 @@ import { apiIntervalsToForm, apiTimePricingToForm, createDefaultTimePricingForm,
import type { AdminGroup, GroupPlatform } from '@/types'
import type { Column } from '@/components/common/types'
import { platformTextClass, platformBadgeLightClass, platformLabel as catalogPlatformLabel } from '@/utils/platformColors'
import { listPlatformIds } from '@/constants/platformCatalog'
import { listPlatformIds, supportsPricingModelSync } from '@/constants/platformCatalog'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
@@ -879,14 +880,15 @@ function addPricingEntry(sectionIdx: number) {
const syncingPlatform = ref<string | null>(null)
async function syncLatestModels(sectionIdx: number) {
const platform = form.platforms[sectionIdx].platform
if (syncingPlatform.value) return
const section = form.platforms[sectionIdx]
if (!section || syncingPlatform.value || !supportsPricingModelSync(section.platform)) return
const platform = section.platform
syncingPlatform.value = platform
try {
const result = await adminAPI.channels.syncPricingModels(platform)
// Collect all model names already present in this platform's pricing entries
const existingModels = new Set<string>()
for (const entry of form.platforms[sectionIdx].model_pricing) {
for (const entry of section.model_pricing) {
for (const m of entry.models) existingModels.add(m)
}
const newModels = result.models.filter(m => !existingModels.has(m))
@@ -895,7 +897,7 @@ async function syncLatestModels(sectionIdx: number) {
return
}
// Add new models as a single new pricing entry (user fills in prices)
form.platforms[sectionIdx].model_pricing.push({
section.model_pricing.push({
models: newModels,
billing_mode: 'token',
input_price: null,
@@ -0,0 +1,155 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
BUILTIN_PLATFORM_CATALOG,
resetPlatformCatalog,
setPlatformCatalog
} from '@/constants/platformCatalog'
import ChannelsView from '@/views/admin/ChannelsView.vue'
const { listChannels, getGroups, syncPricingModels, showSuccess, showError } = vi.hoisted(() => ({
listChannels: vi.fn(),
getGroups: vi.fn(),
syncPricingModels: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
channels: { list: listChannels, syncPricingModels },
groups: { getAll: getGroups },
settings: { getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false }) }
}
}))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess, showError }) }))
vi.mock('vue-i18n', async () => ({
...await vi.importActual<typeof import('vue-i18n')>('vue-i18n'),
useI18n: () => ({ t: (key: string) => key })
}))
function mountView() {
return mount(ChannelsView, {
global: {
stubs: {
AppLayout: defineComponent({ template: '<main><slot /></main>' }),
TablePageLayout: defineComponent({
template: '<section><slot name="filters" /><slot name="table" /><slot name="pagination" /></section>'
}),
BaseDialog: defineComponent({
props: ['show'],
template: '<section v-if="show"><slot /><slot name="footer" /></section>'
}),
PricingEntryCard: defineComponent({
props: ['entry'],
template: '<div class="pricing-entry">{{ entry.models.join(", ") }}</div>'
}),
DataTable: true,
Pagination: true,
ConfirmDialog: true,
EmptyState: true,
Select: true,
Icon: true,
PlatformIcon: true,
Toggle: true
}
}
})
}
async function openDialog(wrapper: ReturnType<typeof mountView>) {
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === 'admin.channels.createChannel')!.trigger('click')
await flushPromises()
}
async function enablePlatform(wrapper: ReturnType<typeof mountView>, platform: string) {
const label = wrapper.findAll('label').find(label => label.text() === `admin.groups.platforms.${platform}`)
expect(label).toBeDefined()
await label!.get('input[type="checkbox"]').setValue(true)
await wrapper.findAll('button.channel-tab').find(button => button.text() === `admin.groups.platforms.${platform}`)!.trigger('click')
}
function visibleSyncButtons(wrapper: ReturnType<typeof mountView>) {
return wrapper.findAll('button').filter(button => button.isVisible() && [
'admin.channels.form.syncLatestModels',
'admin.channels.form.syncingModels'
].includes(button.text()))
}
describe('ChannelsView pricing model sync', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
listChannels.mockResolvedValue({ items: [], total: 0 })
getGroups.mockResolvedValue([])
syncPricingModels.mockReset()
})
afterEach(() => {
resetPlatformCatalog()
})
it.each([
['openai', true],
['opencode_go', true],
['typesafe', true],
['command_code', false],
['cline', false]
] as const)('offers pricing model sync for %s only when its pricing catalog exists', async (platform, expected) => {
const wrapper = mountView()
await openDialog(wrapper)
await enablePlatform(wrapper, platform)
expect(visibleSyncButtons(wrapper)).toHaveLength(expected ? 1 : 0)
expect(syncPricingModels).not.toHaveBeenCalled()
wrapper.unmount()
})
it('reacts to a new platform gaining a pricing catalog', async () => {
const platform = { id: 'acme_router', display_name: 'Acme Router', gateway: 'openai' as const, cn_provider: false }
setPlatformCatalog({
...BUILTIN_PLATFORM_CATALOG,
platforms: [...BUILTIN_PLATFORM_CATALOG.platforms, platform]
})
const wrapper = mountView()
await openDialog(wrapper)
await enablePlatform(wrapper, platform.id)
expect(visibleSyncButtons(wrapper)).toHaveLength(0)
setPlatformCatalog({
...BUILTIN_PLATFORM_CATALOG,
platforms: [...BUILTIN_PLATFORM_CATALOG.platforms, { ...platform, litellm_provider: 'acme' }]
})
await flushPromises()
expect(visibleSyncButtons(wrapper)).toHaveLength(1)
wrapper.unmount()
})
it('blocks additional syncs while one request is in flight and adds its models once', async () => {
let resolveSync!: (value: { models: string[] }) => void
syncPricingModels.mockReturnValue(new Promise(resolve => { resolveSync = resolve }))
const wrapper = mountView()
await openDialog(wrapper)
await enablePlatform(wrapper, 'openai')
await visibleSyncButtons(wrapper)[0].trigger('click')
await visibleSyncButtons(wrapper)[0].trigger('click')
await enablePlatform(wrapper, 'opencode_go')
expect(visibleSyncButtons(wrapper)[0].attributes('disabled')).toBeDefined()
await visibleSyncButtons(wrapper)[0].trigger('click')
expect(syncPricingModels).toHaveBeenCalledOnce()
expect(syncPricingModels).toHaveBeenCalledWith('openai')
resolveSync({ models: ['gpt-latest'] })
await flushPromises()
expect(wrapper.findAll('.pricing-entry')).toHaveLength(1)
expect(wrapper.get('.pricing-entry').text()).toBe('gpt-latest')
expect(visibleSyncButtons(wrapper)[0].attributes('disabled')).toBeUndefined()
expect(showError).not.toHaveBeenCalled()
wrapper.unmount()
})
})