mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
fix(frontend): 按平台能力展示模型同步入口并同步 Zen 规则
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user