diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index 0e9fe1d6e9..d953c21ce9 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -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 { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 63aa5d1631..edd67a1d9f 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -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' } ] }) diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts index 576df0d0b9..9e27d29db9 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -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 ') diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts index 4feec2d4f0..e706dffbdd 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts @@ -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' } ] diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index 7ce6a1f4a2..0550d4b4ab 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -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') }) diff --git a/frontend/src/constants/platformCatalog.builtin.json b/frontend/src/constants/platformCatalog.builtin.json index c17a6ff78a..1d7ec501d3 100644 --- a/frontend/src/constants/platformCatalog.builtin.json +++ b/frontend/src/constants/platformCatalog.builtin.json @@ -206,6 +206,10 @@ "pattern": "claude-*", "protocol": "anthropic" }, + { + "pattern": "qwen3.8-max", + "protocol": "chat_completions" + }, { "pattern": "qwen*", "protocol": "anthropic" diff --git a/frontend/src/constants/platformCatalog.ts b/frontend/src/constants/platformCatalog.ts index 9f1ac68990..16bc44e22a 100644 --- a/frontend/src/constants/platformCatalog.ts +++ b/frontend/src/constants/platformCatalog.ts @@ -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 ?? '' diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index 29da3b78fa..bdb372b7fb 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -423,9 +423,10 @@