From 7b612b8b5add270b7e1f69e5b1366ffa15fb0a7e Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:49:42 +0800 Subject: [PATCH] feat(frontend): persist model pricing sources --- .../components/GlobalModelFormDialog.vue | 65 +- .../GlobalModelPricingSourceSelect.vue | 146 +++++ .../GlobalModelFormDialog.prefill.spec.ts | 75 ++- .../useModelsDevPricingSources.spec.ts | 62 +- .../composables/useModelsDevPricingSources.ts | 77 ++- frontend/src/i18n/messages.ts | 130 ++++ frontend/src/views/admin/ModelManagement.vue | 583 ++++++++++++++---- .../ModelManagement.pricing-source.spec.ts | 296 +++++++++ 8 files changed, 1288 insertions(+), 146 deletions(-) create mode 100644 frontend/src/features/models/components/GlobalModelPricingSourceSelect.vue create mode 100644 frontend/src/views/admin/__tests__/ModelManagement.pricing-source.spec.ts diff --git a/frontend/src/features/models/components/GlobalModelFormDialog.vue b/frontend/src/features/models/components/GlobalModelFormDialog.vue index c091c86db..a8ad73510 100644 --- a/frontend/src/features/models/components/GlobalModelFormDialog.vue +++ b/frontend/src/features/models/components/GlobalModelFormDialog.vue @@ -408,10 +408,14 @@ + + {{ currentOnlinePricingSource + ? t('models.pricingSource.buttonCurrent', { provider: currentOnlinePricingSource.provider_name }) + : t('models.pricingSource.choose') }} + () const { success, error: showError } = useToast() +const { t } = useI18n() const { getSource, setSource } = useModelsDevPricingSources() const submitting = ref(false) const syncingOnlinePricing = ref(false) @@ -842,6 +858,9 @@ const selectedOnlinePricingCandidate = computed(() => ( candidate.providerId === selectedOnlinePricingProviderId.value )) ?? null )) +const currentOnlinePricingSource = computed(() => ( + props.model ? getSource(props.model.id, props.model.config) : null +)) const firstSyncableOnlinePricingProviderId = computed(() => ( onlinePricingCandidates.value.find(isOnlinePricingCandidateSyncable)?.providerId ?? '' )) @@ -1407,7 +1426,7 @@ function resolveOnlinePricingModel( ): ModelsDevModelItem | null { const modelId = normalizeModelId(model.name) const transientSource = editingOnlinePricingSource.value - const storedSource = getSource(model.id) + const storedSource = getSource(model.id, model.config) const preferredProviderId = transientSource?.model_id && normalizeModelId(transientSource.model_id) === modelId ? transientSource.provider_id : storedSource?.provider_id @@ -1530,28 +1549,44 @@ async function applyOnlinePricingModel(onlineModel: ModelsDevModelItem) { props.model.default_tiered_pricing, pricing, ) + const pricingSource = { + provider_id: onlineModel.providerId, + provider_name: onlineModel.providerName, + } + const sourceChanged = !modelsDevPricingSourcesEqual( + getModelsDevPricingSourceFromConfig(props.model.config), + pricingSource, + ) + const nextConfig = withModelsDevPricingSource(props.model.config, pricingSource) let syncedModel: GlobalModelResponse - if (pricingChanged) { - syncedModel = await updateGlobalModel(props.model.id, { + if (pricingChanged || sourceChanged) { + const updatedModel = await updateGlobalModel(props.model.id, { default_tiered_pricing: pricing, + config: nextConfig, }) + syncedModel = { + ...updatedModel, + default_tiered_pricing: pricing, + config: nextConfig, + } } else { syncedModel = { ...props.model, default_tiered_pricing: pricing, + config: nextConfig, } } tieredPricing.value = cloneTieredPricingConfig(pricing) + form.value.config = { ...nextConfig } billingMode.value = 'token' - setSource(props.model.id, { - provider_id: onlineModel.providerId, - provider_name: onlineModel.providerName, - }) + setSource(props.model.id, pricingSource) emit('pricingSynced', syncedModel) success( pricingChanged ? `已同步 ${onlineModel.providerName} 的最新价格` - : `当前价格已是 ${onlineModel.providerName} 的最新价格`, + : sourceChanged + ? t('models.pricingSource.savedNoPriceChange', { provider: onlineModel.providerName }) + : `当前价格已是 ${onlineModel.providerName} 的最新价格`, ) } @@ -1725,6 +1760,12 @@ async function handleSubmit() { success('模型更新成功') } else { const createData = buildGlobalModelCreatePayload(form.value, finalTieredPricing) + if (selectedModel.value) { + createData.config = withModelsDevPricingSource(createData.config, { + provider_id: selectedModel.value.providerId, + provider_name: selectedModel.value.providerName, + }) + } const createdModel = await createGlobalModel(createData) existingModelsCache.value.unshift(createdModel) if (selectedModel.value) { diff --git a/frontend/src/features/models/components/GlobalModelPricingSourceSelect.vue b/frontend/src/features/models/components/GlobalModelPricingSourceSelect.vue new file mode 100644 index 000000000..37c80437b --- /dev/null +++ b/frontend/src/features/models/components/GlobalModelPricingSourceSelect.vue @@ -0,0 +1,146 @@ + + + + + + + + {{ source?.provider_name || t('models.pricingSource.choose') }} + + + + + {{ t('models.pricingSource.loadingOptions') }} + + + + + + {{ candidate.providerName }} + + + {{ candidate.providerId }} + + + + + {{ t('models.pricingSource.inputPrice', { price: formatPrice(candidate.inputPrice) }) }} + {{ t('models.pricingSource.outputPrice', { price: formatPrice(candidate.outputPrice) }) }} + + {{ getUnavailableReason(candidate) }} + + + + + {{ t('models.pricingSource.catalogEmpty') }} + + + + + + + + + {{ t('models.pricingSource.pendingDatabase') }} + + + + + diff --git a/frontend/src/features/models/components/__tests__/GlobalModelFormDialog.prefill.spec.ts b/frontend/src/features/models/components/__tests__/GlobalModelFormDialog.prefill.spec.ts index b782ecb62..7396f90d5 100644 --- a/frontend/src/features/models/components/__tests__/GlobalModelFormDialog.prefill.spec.ts +++ b/frontend/src/features/models/components/__tests__/GlobalModelFormDialog.prefill.spec.ts @@ -306,6 +306,10 @@ describe('GlobalModelFormDialog preset replacement', () => { family: 'fresh-family', input_modalities: ['text'], output_modalities: ['text'], + models_dev_pricing_source: { + provider_id: 'openai', + provider_name: 'OpenAI', + }, }, default_tiered_pricing: { tiers: [ @@ -477,9 +481,17 @@ describe('GlobalModelFormDialog preset replacement', () => { it('refreshes and applies the latest online price from the edit dialog', async () => { const existingStaleModel = buildExistingStaleModel() + const nextConfig = { + ...existingStaleModel.config, + models_dev_pricing_source: { + provider_id: stalePreset.providerId, + provider_name: stalePreset.providerName, + }, + } const syncedModel = { ...existingStaleModel, default_tiered_pricing: stalePreset.tieredPricing!, + config: nextConfig, } globalModelMocks.updateGlobalModel.mockResolvedValue(syncedModel) globalModelMocks.listGlobalModels.mockResolvedValue({ @@ -495,7 +507,7 @@ describe('GlobalModelFormDialog preset replacement', () => { '[data-testid="sync-online-pricing"]', ) if (!syncButton) throw new Error('Missing online pricing sync button') - expect(syncButton.title).toBe('同步最新在线价格') + expect(syncButton.title).toBe('选择并同步在线价格来源') expect(syncButton.getAttribute('aria-label')).toBe('同步最新在线价格') syncButton.click() @@ -505,7 +517,10 @@ describe('GlobalModelFormDialog preset replacement', () => { expect(modelsDevMocks.refreshModelsDevList).toHaveBeenCalledWith(false) expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith( existingStaleModel.id, - { default_tiered_pricing: stalePreset.tieredPricing }, + { + default_tiered_pricing: stalePreset.tieredPricing, + config: nextConfig, + }, ) expect(pricingSynced).toHaveBeenCalledWith(syncedModel) expect(document.body.querySelector('[data-testid="tier-input-price"]')?.value) @@ -524,11 +539,62 @@ describe('GlobalModelFormDialog preset replacement', () => { }) }) + it('persists a newly selected source even when prices already match and keeps it on save', async () => { + const existingModel = { + ...buildExistingStaleModel(), + default_tiered_pricing: stalePreset.tieredPricing!, + } + const nextConfig = { + ...existingModel.config, + models_dev_pricing_source: { + provider_id: stalePreset.providerId, + provider_name: stalePreset.providerName, + }, + } + const syncedModel = { ...existingModel, config: nextConfig } + globalModelMocks.updateGlobalModel.mockResolvedValue(syncedModel) + const { editingModel, pricingSynced } = mountDialog() + await settle() + + editingModel.value = existingModel + await settle() + const syncButton = document.body.querySelector( + '[data-testid="sync-online-pricing"]', + ) + if (!syncButton) throw new Error('Missing online pricing sync button') + syncButton.click() + await settle() + + expect(globalModelMocks.updateGlobalModel).toHaveBeenNthCalledWith( + 1, + existingModel.id, + { + default_tiered_pricing: stalePreset.tieredPricing, + config: nextConfig, + }, + ) + expect(pricingSynced).toHaveBeenCalledWith(syncedModel) + + findExactButton('保存').click() + await settle() + + expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledTimes(2) + expect(globalModelMocks.updateGlobalModel.mock.calls[1][1].config).toEqual(nextConfig) + }) + it('offers a provider choice when the remembered source is unavailable', async () => { const existingStaleModel = buildExistingStaleModel() + const nextConfig = { + ...existingStaleModel.config, + models_dev_pricing_source: { + provider_id: alternateStalePreset.providerId, + provider_name: alternateStalePreset.providerName, + }, + } const syncedModel = { ...existingStaleModel, default_tiered_pricing: alternateStalePreset.tieredPricing!, + config: nextConfig, } modelsDevMocks.refreshModelsDevList.mockResolvedValue([ unavailableStalePreset, @@ -578,7 +644,10 @@ describe('GlobalModelFormDialog preset replacement', () => { expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith( existingStaleModel.id, - { default_tiered_pricing: alternateStalePreset.tieredPricing }, + { + default_tiered_pricing: alternateStalePreset.tieredPricing, + config: nextConfig, + }, ) expect(pricingSynced).toHaveBeenCalledWith(syncedModel) expect(document.body.textContent).not.toContain('选择在线价格来源') diff --git a/frontend/src/features/models/composables/__tests__/useModelsDevPricingSources.spec.ts b/frontend/src/features/models/composables/__tests__/useModelsDevPricingSources.spec.ts index 33c5a6ca1..9711a5078 100644 --- a/frontend/src/features/models/composables/__tests__/useModelsDevPricingSources.spec.ts +++ b/frontend/src/features/models/composables/__tests__/useModelsDevPricingSources.spec.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { useModelsDevPricingSources } from '../useModelsDevPricingSources' +import { + getModelsDevPricingSourceFromConfig, + modelsDevPricingSourcesEqual, + useModelsDevPricingSources, + withModelsDevPricingSource, +} from '../useModelsDevPricingSources' const STORAGE_KEY = 'aether:models-dev-pricing-sources:v1' const LEGACY_STORAGE_KEY = 'aether:models-dev-pricing-preferences:v1' @@ -33,6 +38,24 @@ describe('useModelsDevPricingSources', () => { }) }) + it('prefers the database-backed model config over the local migration fallback', () => { + const { getSource, setSource } = useModelsDevPricingSources() + setSource('model-1', { + provider_id: 'openai', + provider_name: 'OpenAI', + }) + + expect(getSource('model-1', { + models_dev_pricing_source: { + provider_id: 'anthropic', + provider_name: 'Anthropic', + }, + })).toEqual({ + provider_id: 'anthropic', + provider_name: 'Anthropic', + }) + }) + it('migrates the previous provider record without retaining its automatic preference key', () => { localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({ version: 1, @@ -65,4 +88,39 @@ describe('useModelsDevPricingSources', () => { expect(getSource('model-1')).toBeNull() }) -}) \ No newline at end of file +}) + +describe('database-backed models.dev pricing sources', () => { + it('merges the source into model config without dropping unrelated settings', () => { + const config = withModelsDevPricingSource({ + streaming: true, + billing: { video: { price_per_second: 0.1 } }, + }, { + provider_id: ' google ', + provider_name: ' Google ', + }) + + expect(config).toEqual({ + streaming: true, + billing: { video: { price_per_second: 0.1 } }, + models_dev_pricing_source: { + provider_id: 'google', + provider_name: 'Google', + }, + }) + expect(getModelsDevPricingSourceFromConfig(config)).toEqual({ + provider_id: 'google', + provider_name: 'Google', + }) + }) + + it('rejects malformed config records and compares provider ids case-insensitively', () => { + expect(getModelsDevPricingSourceFromConfig({ + models_dev_pricing_source: { provider_id: '', provider_name: 'Missing id' }, + })).toBeNull() + expect(modelsDevPricingSourcesEqual( + { provider_id: 'OpenAI', provider_name: 'OpenAI' }, + { provider_id: 'openai', provider_name: 'OpenAI' }, + )).toBe(true) + }) +}) diff --git a/frontend/src/features/models/composables/useModelsDevPricingSources.ts b/frontend/src/features/models/composables/useModelsDevPricingSources.ts index 8ab71adc4..8d2da5593 100644 --- a/frontend/src/features/models/composables/useModelsDevPricingSources.ts +++ b/frontend/src/features/models/composables/useModelsDevPricingSources.ts @@ -5,6 +5,8 @@ export interface ModelsDevPricingSource { provider_name: string } +export const MODELS_DEV_PRICING_SOURCE_CONFIG_KEY = 'models_dev_pricing_source' + interface StoredModelsDevPricingSources { version: 1 models: Record @@ -14,6 +16,50 @@ const STORAGE_KEY = 'aether:models-dev-pricing-sources:v1' const LEGACY_STORAGE_KEY = 'aether:models-dev-pricing-preferences:v1' const sources = ref>({}) +function normalizePricingSource(value: unknown): ModelsDevPricingSource | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const source = value as Partial + const providerId = typeof source.provider_id === 'string' ? source.provider_id.trim() : '' + const providerName = typeof source.provider_name === 'string' ? source.provider_name.trim() : '' + if (!providerId || !providerName) return null + return { + provider_id: providerId, + provider_name: providerName, + } +} + +/** + * Reads the shared price-source record persisted with global_models.config. + * localStorage remains only as a migration fallback for records created by + * older Aether builds. + */ +export function getModelsDevPricingSourceFromConfig( + config: Record | null | undefined, +): ModelsDevPricingSource | null { + if (!config || typeof config !== 'object' || Array.isArray(config)) return null + return normalizePricingSource(config[MODELS_DEV_PRICING_SOURCE_CONFIG_KEY]) +} + +export function withModelsDevPricingSource( + config: Record | null | undefined, + source: ModelsDevPricingSource, +): Record { + const normalizedSource = normalizePricingSource(source) + if (!normalizedSource) return { ...(config ?? {}) } + return { + ...(config ?? {}), + [MODELS_DEV_PRICING_SOURCE_CONFIG_KEY]: normalizedSource, + } +} + +export function modelsDevPricingSourcesEqual( + left: ModelsDevPricingSource | null | undefined, + right: ModelsDevPricingSource | null | undefined, +): boolean { + return left?.provider_id.trim().toLowerCase() === right?.provider_id.trim().toLowerCase() + && left?.provider_name.trim() === right?.provider_name.trim() +} + function parseStoredSources(key: string): Record | null { try { const stored = JSON.parse(localStorage.getItem(key) || 'null') as unknown @@ -23,19 +69,8 @@ function parseStoredSources(key: string): Record const validSources: Record = {} for (const [modelId, value] of Object.entries(document.models)) { - if (!value || typeof value !== 'object') continue - const source = value as Partial - if ( - typeof source.provider_id === 'string' - && source.provider_id.length > 0 - && typeof source.provider_name === 'string' - && source.provider_name.length > 0 - ) { - validSources[modelId] = { - provider_id: source.provider_id, - provider_name: source.provider_name, - } - } + const source = normalizePricingSource(value) + if (source) validSources[modelId] = source } return validSources } catch { @@ -75,14 +110,23 @@ function readStoredSources(): Record { export function useModelsDevPricingSources() { sources.value = readStoredSources() - function getSource(modelId: string): ModelsDevPricingSource | null { + function getLocalSource(modelId: string): ModelsDevPricingSource | null { return sources.value[modelId] ?? null } + function getSource( + modelId: string, + config?: Record | null, + ): ModelsDevPricingSource | null { + return getModelsDevPricingSourceFromConfig(config) ?? getLocalSource(modelId) + } + function setSource(modelId: string, source: ModelsDevPricingSource) { + const normalizedSource = normalizePricingSource(source) + if (!normalizedSource) return const nextSources = { ...sources.value, - [modelId]: source, + [modelId]: normalizedSource, } sources.value = nextSources writeStoredSources(nextSources) @@ -90,6 +134,7 @@ export function useModelsDevPricingSources() { return { getSource, + getLocalSource, setSource, } -} \ No newline at end of file +} diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 60142b6ed..d67766d4a 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -323,6 +323,71 @@ export const messages = { 'models.externalCatalog.saved': '外部模型目录代理节点已保存', 'models.externalCatalog.saveFailed': '保存外部模型目录代理节点失败', 'models.externalCatalog.loadFailed': '加载外部模型目录代理节点失败', + 'models.pricingSource.label': '价格来源', + 'models.pricingSource.choose': '选择来源', + 'models.pricingSource.currentTitle': '当前来源:{provider};选择其他来源会立即同步价格', + 'models.pricingSource.chooseTitle': '选择来源并立即同步在线价格', + 'models.pricingSource.editCurrentTitle': '当前来源:{provider};点击选择或重新同步', + 'models.pricingSource.editChooseTitle': '选择并同步在线价格来源', + 'models.pricingSource.syncingTitle': '正在同步在线价格', + 'models.pricingSource.buttonCurrent': '价格来源 · {provider}', + 'models.pricingSource.loading': '加载来源...', + 'models.pricingSource.loadingOptions': '正在加载在线价格来源...', + 'models.pricingSource.catalogEmpty': 'models.dev 暂无匹配来源', + 'models.pricingSource.resyncTitle': '从当前来源重新同步价格', + 'models.pricingSource.pendingDatabase': '待保存到数据库', + 'models.pricingSource.incompatible': '计价不兼容', + 'models.pricingSource.noTokenPrice': '无 Token 价格', + 'models.pricingSource.inputPrice': '输入 ${price}', + 'models.pricingSource.outputPrice': '输出 ${price}', + 'models.pricingSource.sourceGone': '所选价格来源不再提供该模型,请刷新在线目录后重试', + 'models.pricingSource.cannotSync': '无法同步', + 'models.pricingSource.unsupported': '该来源包含当前计费引擎无法独立结算的{fields}', + 'models.pricingSource.noUsablePrice': '该来源没有可用的 Token 价格', + 'models.pricingSource.selectedAndSynced': '已选择 {provider} 并同步模型价格', + 'models.pricingSource.updateFailed': '更新模型价格来源失败', + 'models.pricingSource.syncFailed': '同步失败', + 'models.pricingSource.savedNoPriceChange': '已保存 {provider} 为价格来源,当前价格无需更新', + 'models.pricingField.reasoning': '推理 Token', + 'models.pricingField.inputAudio': '输入音频 Token', + 'models.pricingField.outputAudio': '输出音频 Token', + 'models.management.selectedCount': '已选 {count} 个', + 'models.management.manageSelectedTitle': '管理已选择的 {count} 个模型', + 'models.management.batchButtonSelected': '批量操作 ({count})', + 'models.management.batchButton': '批量管理', + 'models.management.selectCurrentPage': '选择当前页模型', + 'models.management.selectModel': '选择模型 {name}', + 'models.management.batch.title': '快速筛选与批量操作', + 'models.management.batch.description': '默认按每个模型保存在数据库中的在线来源同步,也可手动指定统一来源', + 'models.management.batch.providerPlaceholder': '选择在线价格来源', + 'models.management.batch.rememberedProvider': '上次选择(按模型)', + 'models.management.batch.selectionEmpty': '选择模型后执行批量操作', + 'models.management.batch.selectionSummary': '已选择 {selected} 个 · 可同步 {syncable} · 待保存来源 {sourcePending} · 已一致 {unchanged} · 不兼容 {unsupported} · 无在线价格 {unavailable}', + 'models.management.batch.state.syncable': '价格可更新', + 'models.management.batch.state.sourcePending': '来源待保存', + 'models.management.batch.state.unchanged': '价格一致', + 'models.management.batch.state.unsupported': '计价不兼容', + 'models.management.batch.state.unavailable': '无在线价格', + 'models.management.batch.source.noneSelected': '未选择来源', + 'models.management.batch.source.noneRecorded': '未记录来源', + 'models.management.batch.shortcut.syncable': '当前价格与所选供应商在线价格不同', + 'models.management.batch.shortcut.sourcePending': '价格已一致,但来源尚未保存到数据库', + 'models.management.batch.shortcut.unchanged': '当前价格与所选供应商在线价格一致', + 'models.management.batch.shortcut.unsupported': '在线来源包含当前计费引擎无法独立结算的价格维度', + 'models.management.batch.shortcut.unavailable': '所选供应商没有该模型的在线价格', + 'models.management.batch.syncButton': '同步价格与来源 ({count})', + 'models.management.batch.syncing': '同步中...', + 'models.management.batch.confirmTitle': '批量同步价格与来源', + 'models.management.batch.confirmMain': '将根据 {provider} 为 {count} 个模型同步在线价格并把价格来源保存到数据库。', + 'models.management.batch.confirmSourceOnly': '其中 {count} 个模型价格已一致,只保存来源。', + 'models.management.batch.confirmSkipped': '另有 {count} 个模型因价格已一致且来源已保存、计价不兼容或无在线价格而跳过。', + 'models.management.batch.confirmUnchanged': '不会修改模型名称、能力或其他配置。', + 'models.management.batch.confirmButton': '同步并保存', + 'models.management.batch.rememberedProviderLabel': '各模型上次选择的提供商', + 'models.management.batch.selectedProviderLabel': '所选供应商', + 'models.management.batch.success': '成功同步 {count} 个模型的价格与来源', + 'models.management.batch.partialFailure': '{count} 个模型同步失败:{details}', + 'models.management.batch.catalogMissingPrice': '在线目录未提供价格配置', 'nav.routing': '调度策略', 'nav.pool': '号池管理', 'nav.standaloneKeys': '独立密钥', @@ -675,6 +740,71 @@ export const messages = { 'models.externalCatalog.saved': 'External model catalog proxy saved', 'models.externalCatalog.saveFailed': 'Failed to save external model catalog proxy', 'models.externalCatalog.loadFailed': 'Failed to load external model catalog proxy', + 'models.pricingSource.label': 'Price source', + 'models.pricingSource.choose': 'Choose source', + 'models.pricingSource.currentTitle': 'Current source: {provider}. Choosing another source syncs its prices immediately.', + 'models.pricingSource.chooseTitle': 'Choose a source and sync its online prices immediately', + 'models.pricingSource.editCurrentTitle': 'Current source: {provider}. Click to choose another source or sync again.', + 'models.pricingSource.editChooseTitle': 'Choose and sync an online price source', + 'models.pricingSource.syncingTitle': 'Syncing online prices', + 'models.pricingSource.buttonCurrent': 'Price source · {provider}', + 'models.pricingSource.loading': 'Loading sources...', + 'models.pricingSource.loadingOptions': 'Loading online price sources...', + 'models.pricingSource.catalogEmpty': 'No matching source on models.dev', + 'models.pricingSource.resyncTitle': 'Sync prices again from the current source', + 'models.pricingSource.pendingDatabase': 'Pending database save', + 'models.pricingSource.incompatible': 'Incompatible pricing', + 'models.pricingSource.noTokenPrice': 'No token prices', + 'models.pricingSource.inputPrice': 'In ${price}', + 'models.pricingSource.outputPrice': 'Out ${price}', + 'models.pricingSource.sourceGone': 'The selected source no longer provides this model. Refresh the online catalog and try again.', + 'models.pricingSource.cannotSync': 'Unable to sync', + 'models.pricingSource.unsupported': 'This source includes {fields}, which the current billing engine cannot price independently.', + 'models.pricingSource.noUsablePrice': 'This source has no usable token prices.', + 'models.pricingSource.selectedAndSynced': 'Selected {provider} and synced the model prices', + 'models.pricingSource.updateFailed': 'Failed to update the model price source', + 'models.pricingSource.syncFailed': 'Sync failed', + 'models.pricingSource.savedNoPriceChange': 'Saved {provider} as the price source; prices were already current', + 'models.pricingField.reasoning': 'reasoning tokens', + 'models.pricingField.inputAudio': 'input audio tokens', + 'models.pricingField.outputAudio': 'output audio tokens', + 'models.management.selectedCount': '{count} selected', + 'models.management.manageSelectedTitle': 'Manage the {count} selected models', + 'models.management.batchButtonSelected': 'Batch actions ({count})', + 'models.management.batchButton': 'Batch manage', + 'models.management.selectCurrentPage': 'Select models on this page', + 'models.management.selectModel': 'Select model {name}', + 'models.management.batch.title': 'Filters and batch actions', + 'models.management.batch.description': 'Prices sync from each model’s source saved in the database by default, or you can choose one source for all selected models.', + 'models.management.batch.providerPlaceholder': 'Choose an online price source', + 'models.management.batch.rememberedProvider': 'Saved choice for each model', + 'models.management.batch.selectionEmpty': 'Select models to run batch actions', + 'models.management.batch.selectionSummary': '{selected} selected · {syncable} to sync · {sourcePending} sources to save · {unchanged} current · {unsupported} incompatible · {unavailable} without online prices', + 'models.management.batch.state.syncable': 'Price update available', + 'models.management.batch.state.sourcePending': 'Source not saved', + 'models.management.batch.state.unchanged': 'Price current', + 'models.management.batch.state.unsupported': 'Incompatible pricing', + 'models.management.batch.state.unavailable': 'No online price', + 'models.management.batch.source.noneSelected': 'No source selected', + 'models.management.batch.source.noneRecorded': 'No source saved', + 'models.management.batch.shortcut.syncable': 'The current price differs from the selected source', + 'models.management.batch.shortcut.sourcePending': 'The price matches, but the source has not been saved to the database', + 'models.management.batch.shortcut.unchanged': 'The current price matches the selected source', + 'models.management.batch.shortcut.unsupported': 'The online source includes price dimensions the billing engine cannot price independently', + 'models.management.batch.shortcut.unavailable': 'The selected source has no online price for this model', + 'models.management.batch.syncButton': 'Sync prices and sources ({count})', + 'models.management.batch.syncing': 'Syncing...', + 'models.management.batch.confirmTitle': 'Sync prices and sources', + 'models.management.batch.confirmMain': 'Sync online prices for {count} models from {provider} and save each price source to the database.', + 'models.management.batch.confirmSourceOnly': '{count} models already have matching prices, so only their sources will be saved.', + 'models.management.batch.confirmSkipped': '{count} models will be skipped because their prices and saved sources already match, their pricing is incompatible, or no online price is available.', + 'models.management.batch.confirmUnchanged': 'Model names, capabilities, and other settings will not change.', + 'models.management.batch.confirmButton': 'Sync and save', + 'models.management.batch.rememberedProviderLabel': 'each model’s saved provider', + 'models.management.batch.selectedProviderLabel': 'the selected provider', + 'models.management.batch.success': 'Synced prices and sources for {count} models', + 'models.management.batch.partialFailure': '{count} models failed to sync: {details}', + 'models.management.batch.catalogMissingPrice': 'The online catalog did not provide a price configuration', 'nav.routing': 'Routing', 'nav.pool': 'Pool', 'nav.standaloneKeys': 'Standalone keys', diff --git a/frontend/src/views/admin/ModelManagement.vue b/frontend/src/views/admin/ModelManagement.vue index 04ebc058f..e8b09d9cc 100644 --- a/frontend/src/views/admin/ModelManagement.vue +++ b/frontend/src/views/admin/ModelManagement.vue @@ -8,9 +8,18 @@ - - 模型管理 - + + + 模型管理 + + + {{ t('models.management.selectedCount', { count: selectedBatchManageModelIds.size }) }} + + @@ -28,13 +37,18 @@ + {{ selectedBatchManageModelIds.size > 0 + ? t('models.management.batchButtonSelected', { count: selectedBatchManageModelIds.size }) + : t('models.management.batchButton') }} - - 模型名称 + + + + 模型名称 + 价格 ($/M) + + {{ t('models.pricingSource.label') }} + 提供商 @@ -80,7 +108,7 @@ @@ -88,7 +116,7 @@ 没有找到匹配的模型 @@ -99,23 +127,34 @@ v-for="model in paginatedGlobalModels" :key="model.id" class="cursor-pointer hover:bg-muted/50 group" + :class="selectedBatchManageModelIds.has(model.id) ? 'bg-primary/5' : ''" @mousedown="handleMouseDown" @click="handleRowClick($event, model)" > - - - {{ model.display_name }} - - - {{ model.name }} - - - + + + + + {{ model.display_name }} + + + {{ model.name }} + + + + @@ -157,6 +196,24 @@ + + + {{ model.active_provider_count || 0 }}/{{ model.provider_count || 0 }} @@ -224,28 +281,39 @@ v-for="model in paginatedGlobalModels" :key="model.id" class="p-4 space-y-3 hover:bg-muted/50 cursor-pointer transition-colors" + :class="selectedBatchManageModelIds.has(model.id) ? 'bg-primary/5' : ''" @click="selectModel(model)" > - - - {{ model.display_name }} - - {{ model.is_active ? '活跃' : '停用' }} - - - - {{ model.name }} - - - + + + + + {{ model.display_name }} + + {{ model.is_active ? '活跃' : '停用' }} + + + + {{ model.name }} + + + + + + + {{ t('models.pricingSource.label') }} + + @@ -472,8 +560,8 @@ - + - 上次选择(按模型) + {{ t('models.management.batch.rememberedProvider') }} - {{ batchManageAction === 'sync-prices' ? '同步中...' : `同步在线价格 (${selectedBatchPriceSyncPlan.syncable.length})` }} + {{ batchManageAction === 'sync-prices' + ? t('models.management.batch.syncing') + : t('models.management.batch.syncButton', { count: selectedBatchPriceSyncEntries.length }) }} ([]) const batchManageLoading = ref(false) const batchManageOnlineModels = ref([]) const batchManageOnlineLoading = ref(false) +const modelPricingSourceSyncingIds = ref>(new Set()) const GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE = 1000 let globalModelsRequestId = 0 let modelSelectionRequestId = 0 let modelProvidersRequestId = 0 let batchManageModelsRequestId = 0 +let batchManageOnlineModelsRequest: Promise | null = null +const pricingSourceMigrationAttemptedIds = new Set() let providerOptionsRequest: Promise | null = null const GLOBAL_MODELS_LIST_CACHE_TTL_MS = 10 * 1000 @@ -1097,6 +1204,32 @@ const filteredGlobalModels = computed(() => { // 模型目录分页计算 const paginatedGlobalModels = computed(() => filteredGlobalModels.value) +const selectedOnCurrentModelPageCount = computed(() => ( + paginatedGlobalModels.value.filter(model => selectedBatchManageModelIds.value.has(model.id)).length +)) +const isCurrentModelPageFullySelected = computed(() => ( + paginatedGlobalModels.value.length > 0 + && selectedOnCurrentModelPageCount.value === paginatedGlobalModels.value.length +)) +const isCurrentModelPagePartiallySelected = computed(() => ( + selectedOnCurrentModelPageCount.value > 0 && !isCurrentModelPageFullySelected.value +)) + +function setBatchManageModelSelection(modelId: string, selected: boolean) { + const nextSelection = new Set(selectedBatchManageModelIds.value) + if (selected) nextSelection.add(modelId) + else nextSelection.delete(modelId) + selectedBatchManageModelIds.value = nextSelection +} + +function toggleCurrentModelPageSelection(selected: boolean) { + const nextSelection = new Set(selectedBatchManageModelIds.value) + for (const model of paginatedGlobalModels.value) { + if (selected) nextSelection.add(model.id) + else nextSelection.delete(model.id) + } + selectedBatchManageModelIds.value = nextSelection +} watch(searchQuery, () => { catalogCurrentPage.value = 1 @@ -1133,6 +1266,7 @@ async function loadGlobalModels(options: { cacheTtlMs?: number } = {}) { globalModels.value = pageModels totalGlobalModels.value = total + migrateLegacyModelsDevPricingSources(pageModels) } catch (err: unknown) { if (requestId !== globalModelsRequestId) return log.error('加载模型失败:', err) @@ -1175,6 +1309,11 @@ async function loadBatchManageModels() { if (requestId !== batchManageModelsRequestId) return batchManageModels.value = allModels + const validModelIds = new Set(allModels.map(model => model.id)) + selectedBatchManageModelIds.value = new Set( + [...selectedBatchManageModelIds.value].filter(modelId => validModelIds.has(modelId)), + ) + migrateLegacyModelsDevPricingSources(allModels) } catch (err: unknown) { if (requestId !== batchManageModelsRequestId) return log.error('加载批量管理模型失败:', err) @@ -1369,6 +1508,164 @@ const filteredBatchManageModels = computed(() => { }) }) +const onlinePricingCandidatesByModelName = computed(() => { + const candidatesByName = new Map() + for (const onlineModel of batchManageOnlineModels.value) { + const normalizedName = onlineModel.modelId.trim().toLowerCase() + const existing = candidatesByName.get(normalizedName) ?? [] + const normalizedProviderId = onlineModel.providerId.trim().toLowerCase() + if (!existing.some(candidate => ( + candidate.providerId.trim().toLowerCase() === normalizedProviderId + ))) { + existing.push(onlineModel) + candidatesByName.set(normalizedName, existing) + } + } + for (const candidates of candidatesByName.values()) { + candidates.sort((left, right) => ( + Number(right.official === true) - Number(left.official === true) + || left.providerName.localeCompare(right.providerName) + || left.providerId.localeCompare(right.providerId) + )) + } + return candidatesByName +}) + +function getModelPricingCandidates(model: GlobalModelResponse): ModelsDevModelItem[] { + return onlinePricingCandidatesByModelName.value.get(model.name.trim().toLowerCase()) ?? [] +} + +function getModelPricingSource(model: GlobalModelResponse) { + return getModelsDevPricingSource(model.id, model.config) +} + +function isModelPricingSourceLocalOnly(model: GlobalModelResponse): boolean { + return !getModelsDevPricingSourceFromConfig(model.config) + && !!getLocalModelsDevPricingSource(model.id) +} + +function isModelPricingSourceSyncing(modelId: string): boolean { + return modelPricingSourceSyncingIds.value.has(modelId) +} + +function setModelPricingSourceSyncing(modelId: string, syncing: boolean) { + const nextIds = new Set(modelPricingSourceSyncingIds.value) + if (syncing) nextIds.add(modelId) + else nextIds.delete(modelId) + modelPricingSourceSyncingIds.value = nextIds +} + +function applyGlobalModelUpdate(updatedModel: GlobalModelResponse) { + for (const models of [globalModels.value, batchManageModels.value]) { + const current = models.find(model => model.id === updatedModel.id) + if (current) Object.assign(current, updatedModel) + } + if (editingModel.value?.id === updatedModel.id) { + editingModel.value = { ...editingModel.value, ...updatedModel } + } + if (selectedModel.value?.id === updatedModel.id) { + selectedModel.value = { ...selectedModel.value, ...updatedModel } + } +} + +function migrateLegacyModelsDevPricingSources(models: GlobalModelResponse[]) { + const tasks = models.flatMap(model => { + const localSource = getLocalModelsDevPricingSource(model.id) + if ( + !localSource + || getModelsDevPricingSourceFromConfig(model.config) + || pricingSourceMigrationAttemptedIds.has(model.id) + ) { + return [] + } + pricingSourceMigrationAttemptedIds.add(model.id) + return [async () => { + setModelPricingSourceSyncing(model.id, true) + const nextConfig = withModelsDevPricingSource(model.config, localSource) + try { + const updatedModel = await updateGlobalModel(model.id, { config: nextConfig }) + applyGlobalModelUpdate({ ...updatedModel, config: nextConfig }) + } catch (err: unknown) { + pricingSourceMigrationAttemptedIds.delete(model.id) + log.warn('迁移本地模型价格来源失败:', err) + } finally { + setModelPricingSourceSyncing(model.id, false) + } + }] + }) + if (tasks.length > 0) void runBatchTasksWithConcurrency(tasks, 4) +} + +async function handleModelPricingSourceOpen(_model: GlobalModelResponse, open: boolean) { + if (open) await loadBatchManageOnlineModels() +} + +async function syncModelPricingSource(model: GlobalModelResponse, providerId: string) { + if (!providerId || providerId.startsWith('__') || isModelPricingSourceSyncing(model.id)) return + if (batchManageOnlineModels.value.length === 0) await loadBatchManageOnlineModels() + const normalizedProviderId = providerId.trim().toLowerCase() + const candidate = getModelPricingCandidates(model).find(item => ( + item.providerId.trim().toLowerCase() === normalizedProviderId + )) + if (!candidate) { + showError( + t('models.pricingSource.sourceGone'), + t('models.pricingSource.cannotSync'), + ) + return + } + if (candidate.pricingUnsupportedFields?.length) { + showError( + t('models.pricingSource.unsupported', { fields: formatBatchUnsupportedPricingFields(candidate) }), + t('models.pricingSource.incompatible'), + ) + return + } + if (!candidate.tieredPricing?.tiers?.length) { + showError( + t('models.pricingSource.noUsablePrice'), + t('models.pricingSource.cannotSync'), + ) + return + } + + const source = { + provider_id: candidate.providerId, + provider_name: candidate.providerName, + } + const pricing = cloneTieredPricingConfig(candidate.tieredPricing) + const nextConfig = withModelsDevPricingSource(model.config, source) + setModelPricingSourceSyncing(model.id, true) + try { + const updatedModel = await updateGlobalModel(model.id, { + default_tiered_pricing: pricing, + config: nextConfig, + }) + setModelsDevPricingSource(model.id, source) + applyGlobalModelUpdate({ + ...updatedModel, + default_tiered_pricing: pricing, + config: nextConfig, + }) + success(t('models.pricingSource.selectedAndSynced', { provider: candidate.providerName })) + } catch (err: unknown) { + log.error('更新模型价格来源失败:', err) + showError( + parseApiError(err, t('models.pricingSource.updateFailed')), + t('models.pricingSource.syncFailed'), + ) + } finally { + setModelPricingSourceSyncing(model.id, false) + } +} + +async function resyncModelPricingSource(model: GlobalModelResponse) { + const source = getModelPricingSource(model) + if (!source) return + await loadBatchManageOnlineModels() + await syncModelPricingSource(model, source.provider_id) +} + const batchPricingProviderOptions = computed(() => { const existingModelNames = new Set(batchManageModels.value.map(model => model.name.trim().toLowerCase())) const providers = new Map { if (batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID) return undefined const providerIds = new Map() for (const model of batchManageModels.value) { - const source = getModelsDevPricingSource(model.id) + const source = getModelsDevPricingSource(model.id, model.config) if (source) providerIds.set(model.id, source.provider_id) } return providerIds @@ -1429,10 +1726,25 @@ const batchPriceSyncPlan = computed(() => ( ) )) +function doesBatchEntryNeedSourcePersistence(entry: GlobalModelPriceSyncEntry): boolean { + return !modelsDevPricingSourcesEqual( + getModelsDevPricingSourceFromConfig(entry.model.config), + { + provider_id: entry.onlineModel.providerId, + provider_name: entry.onlineModel.providerName, + }, + ) +} + const batchPricingStateByModelId = computed(() => { - const states = new Map() + const states = new Map() for (const entry of batchPriceSyncPlan.value.syncable) states.set(entry.model.id, 'syncable') - for (const entry of batchPriceSyncPlan.value.unchanged) states.set(entry.model.id, 'unchanged') + for (const entry of batchPriceSyncPlan.value.unchanged) { + states.set( + entry.model.id, + doesBatchEntryNeedSourcePersistence(entry) ? 'source-pending' : 'unchanged', + ) + } for (const entry of batchPriceSyncPlan.value.unsupported) states.set(entry.model.id, 'unsupported') for (const model of batchPriceSyncPlan.value.unavailable) states.set(model.id, 'unavailable') return states @@ -1450,11 +1762,25 @@ const selectedBatchPriceSyncPlan = computed(() => ( ) )) +const selectedBatchPriceSyncEntries = computed(() => [ + ...selectedBatchPriceSyncPlan.value.syncable, + ...selectedBatchPriceSyncPlan.value.unchanged.filter(doesBatchEntryNeedSourcePersistence), +]) + const batchManageSelectionSummary = computed(() => { const selectedCount = selectedBatchManageModelIds.value.size - if (selectedCount === 0) return '选择模型后执行批量操作' + if (selectedCount === 0) return t('models.management.batch.selectionEmpty') const plan = selectedBatchPriceSyncPlan.value - return `已选择 ${selectedCount} 个 · 可更新 ${plan.syncable.length} · 已一致 ${plan.unchanged.length} · 不兼容 ${plan.unsupported.length} · 无在线价格 ${plan.unavailable.length}` + const sourcePendingCount = plan.unchanged.filter(doesBatchEntryNeedSourcePersistence).length + const unchangedCount = plan.unchanged.length - sourcePendingCount + return t('models.management.batch.selectionSummary', { + selected: selectedCount, + syncable: plan.syncable.length, + sourcePending: sourcePendingCount, + unchanged: unchangedCount, + unsupported: plan.unsupported.length, + unavailable: plan.unavailable.length, + }) }) function getBatchPricingState(model: GlobalModelResponse) { @@ -1463,10 +1789,11 @@ function getBatchPricingState(model: GlobalModelResponse) { function getBatchPricingStateLabel(model: GlobalModelResponse): string { const state = getBatchPricingState(model) - if (state === 'syncable') return '价格可更新' - if (state === 'unchanged') return '价格一致' - if (state === 'unsupported') return '计价不兼容' - return '无在线价格' + if (state === 'syncable') return t('models.management.batch.state.syncable') + if (state === 'source-pending') return t('models.management.batch.state.sourcePending') + if (state === 'unchanged') return t('models.management.batch.state.unchanged') + if (state === 'unsupported') return t('models.management.batch.state.unsupported') + return t('models.management.batch.state.unavailable') } function getBatchPricingStateDescription(model: GlobalModelResponse): string { @@ -1480,14 +1807,17 @@ function getBatchPricingStateDescription(model: GlobalModelResponse): string { function getBatchPricingSourceLabel(model: GlobalModelResponse): string { if (batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID) { - return selectedBatchPricingProvider.value?.providerName ?? '未选择来源' + return selectedBatchPricingProvider.value?.providerName + ?? t('models.management.batch.source.noneSelected') } - return getModelsDevPricingSource(model.id)?.provider_name ?? '未记录来源' + return getModelsDevPricingSource(model.id, model.config)?.provider_name + ?? t('models.management.batch.source.noneRecorded') } function getBatchPricingStateClass(model: GlobalModelResponse): string { const state = getBatchPricingState(model) if (state === 'syncable') return 'text-amber-700 dark:text-amber-300' + if (state === 'source-pending') return 'text-sky-700 dark:text-sky-300' if (state === 'unchanged') return 'text-emerald-700 dark:text-emerald-300' if (state === 'unsupported') return 'text-rose-700 dark:text-rose-300' return 'text-muted-foreground' @@ -1496,6 +1826,7 @@ function getBatchPricingStateClass(model: GlobalModelResponse): string { function getBatchPricingStateDotClass(model: GlobalModelResponse): string { const state = getBatchPricingState(model) if (state === 'syncable') return 'bg-amber-500' + if (state === 'source-pending') return 'bg-sky-500' if (state === 'unchanged') return 'bg-emerald-500' if (state === 'unsupported') return 'bg-rose-500' return 'bg-muted-foreground/45' @@ -1503,11 +1834,13 @@ function getBatchPricingStateDotClass(model: GlobalModelResponse): string { function formatBatchUnsupportedPricingFields(model: ModelsDevModelItem): string { const labels = { - reasoning: '推理 Token', - input_audio: '输入音频 Token', - output_audio: '输出音频 Token', + reasoning: t('models.pricingField.reasoning'), + input_audio: t('models.pricingField.inputAudio'), + output_audio: t('models.pricingField.outputAudio'), } - return (model.pricingUnsupportedFields ?? []).map(field => labels[field]).join('、') + return (model.pricingUnsupportedFields ?? []) + .map(field => labels[field]) + .join(locale.value === 'zh-CN' ? '、' : ', ') } // 批量管理 - 快捷筛选定义 @@ -1524,10 +1857,11 @@ const batchManageShortcuts = computed(() => { filter: (m: GlobalModelResponse) => boolean emphasis?: boolean }[] = [ - { label: '价格可更新', description: '当前价格与所选供应商在线价格不同', filter: m => getBatchPricingState(m) === 'syncable', emphasis: true }, - { label: '价格一致', description: '当前价格与所选供应商在线价格一致', filter: m => getBatchPricingState(m) === 'unchanged' }, - { label: '计价不兼容', description: '在线来源包含当前计费引擎无法独立结算的价格维度', filter: m => getBatchPricingState(m) === 'unsupported' }, - { label: '无在线价格', description: '所选供应商没有该模型的在线价格', filter: m => getBatchPricingState(m) === 'unavailable' }, + { label: t('models.management.batch.state.syncable'), description: t('models.management.batch.shortcut.syncable'), filter: m => getBatchPricingState(m) === 'syncable', emphasis: true }, + { label: t('models.management.batch.state.sourcePending'), description: t('models.management.batch.shortcut.sourcePending'), filter: m => getBatchPricingState(m) === 'source-pending', emphasis: true }, + { label: t('models.management.batch.state.unchanged'), description: t('models.management.batch.shortcut.unchanged'), filter: m => getBatchPricingState(m) === 'unchanged' }, + { label: t('models.management.batch.state.unsupported'), description: t('models.management.batch.shortcut.unsupported'), filter: m => getBatchPricingState(m) === 'unsupported' }, + { label: t('models.management.batch.state.unavailable'), description: t('models.management.batch.shortcut.unavailable'), filter: m => getBatchPricingState(m) === 'unavailable' }, { label: '无提供商', description: '没有关联任何提供商的模型', filter: m => (m.provider_count || 0) === 0 }, { label: '无活跃提供商', description: '有提供商但没有活跃提供商的模型', filter: m => (m.active_provider_count || 0) === 0 && (m.provider_count || 0) > 0 }, { label: '禁用', description: '被禁用的模型', filter: m => !m.is_active }, @@ -1578,21 +1912,27 @@ function toggleAllBatchManageModels() { function openBatchManageDialog() { batchManageSearchQuery.value = '' batchPricingProviderId.value = REMEMBERED_PRICING_PROVIDER_ID - selectedBatchManageModelIds.value = new Set() batchManageDialogOpen.value = true void Promise.all([loadBatchManageModels(), loadBatchManageOnlineModels()]) } async function loadBatchManageOnlineModels() { - batchManageOnlineLoading.value = true - try { - batchManageOnlineModels.value = await getModelsDevList(false) - } catch (err: unknown) { - log.error('加载在线模型价格失败:', err) - showError(parseApiError(err, '加载在线模型价格失败'), '加载失败') - } finally { - batchManageOnlineLoading.value = false - } + if (batchManageOnlineModels.value.length > 0) return + if (batchManageOnlineModelsRequest) return batchManageOnlineModelsRequest + + batchManageOnlineModelsRequest = (async () => { + batchManageOnlineLoading.value = true + try { + batchManageOnlineModels.value = await getModelsDevList(false) + } catch (err: unknown) { + log.error('加载在线模型价格失败:', err) + showError(parseApiError(err, '加载在线模型价格失败'), '加载失败') + } finally { + batchManageOnlineLoading.value = false + batchManageOnlineModelsRequest = null + } + })() + return batchManageOnlineModelsRequest } async function runBatchTasksWithConcurrency( @@ -1614,15 +1954,29 @@ async function runBatchTasksWithConcurrency( async function confirmBatchSyncPrices() { const plan = selectedBatchPriceSyncPlan.value - if (plan.syncable.length === 0) return + const entries = selectedBatchPriceSyncEntries.value + if (entries.length === 0) return const providerName = batchPricingProviderId.value === REMEMBERED_PRICING_PROVIDER_ID - ? '各模型上次选择的提供商' - : selectedBatchPricingProvider.value?.providerName || '所选供应商' - const skippedCount = plan.unchanged.length + plan.unsupported.length + plan.unavailable.length + ? t('models.management.batch.rememberedProviderLabel') + : selectedBatchPricingProvider.value?.providerName + || t('models.management.batch.selectedProviderLabel') + const sourceOnlyCount = plan.unchanged.filter(doesBatchEntryNeedSourcePersistence).length + const skippedCount = plan.unchanged.length - sourceOnlyCount + + plan.unsupported.length + + plan.unavailable.length + const sourceOnlyMessage = sourceOnlyCount > 0 + ? ` ${t('models.management.batch.confirmSourceOnly', { count: sourceOnlyCount })}` + : '' + const skippedMessage = skippedCount > 0 + ? ` ${t('models.management.batch.confirmSkipped', { count: skippedCount })}` + : '' const confirmed = await confirm({ - title: '批量同步模型价格', - message: `将根据 ${providerName} 的在线定价更新 ${plan.syncable.length} 个模型。${skippedCount > 0 ? `另有 ${skippedCount} 个模型因价格一致、计价不兼容或无在线价格而跳过。` : ''}\n\n仅更新模型价格,不修改名称、能力或其他配置。`, - confirmText: '同步价格', + title: t('models.management.batch.confirmTitle'), + message: `${t('models.management.batch.confirmMain', { + provider: providerName, + count: entries.length, + })}${sourceOnlyMessage}${skippedMessage}\n\n${t('models.management.batch.confirmUnchanged')}`, + confirmText: t('models.management.batch.confirmButton'), variant: 'info', }) if (!confirmed) return @@ -1633,18 +1987,27 @@ async function confirmBatchSyncPrices() { const failureMessages: string[] = [] let successCount = 0 try { - const tasks = plan.syncable.map(entry => async () => { + const tasks = entries.map(entry => async () => { try { const onlinePricing = entry.onlineModel.tieredPricing if (!onlinePricing) { - throw new Error('在线目录未提供价格配置') + throw new Error(t('models.management.batch.catalogMissingPrice')) } const pricing = cloneTieredPricingConfig(onlinePricing) - await updateGlobalModel(entry.model.id, { default_tiered_pricing: pricing }) - entry.model.default_tiered_pricing = pricing - setModelsDevPricingSource(entry.model.id, { + const source = { provider_id: entry.onlineModel.providerId, provider_name: entry.onlineModel.providerName, + } + const nextConfig = withModelsDevPricingSource(entry.model.config, source) + const updatedModel = await updateGlobalModel(entry.model.id, { + default_tiered_pricing: pricing, + config: nextConfig, + }) + setModelsDevPricingSource(entry.model.id, source) + applyGlobalModelUpdate({ + ...updatedModel, + default_tiered_pricing: pricing, + config: nextConfig, }) successCount += 1 } catch (err: unknown) { @@ -1654,9 +2017,17 @@ async function confirmBatchSyncPrices() { }) await runBatchTasksWithConcurrency(tasks) - if (successCount > 0) success(`成功同步 ${successCount} 个模型价格`) + if (successCount > 0) { + success(t('models.management.batch.success', { count: successCount })) + } if (failureMessages.length > 0) { - showError(`${failureMessages.length} 个模型同步失败:${failureMessages.slice(0, 2).join(';')}`, '部分失败') + showError( + t('models.management.batch.partialFailure', { + count: failureMessages.length, + details: failureMessages.slice(0, 2).join(locale.value === 'zh-CN' ? ';' : '; '), + }), + '部分失败', + ) } await Promise.all([loadGlobalModels(), loadBatchManageModels()]) selectedBatchManageModelIds.value = failedIds @@ -1705,12 +2076,6 @@ async function confirmBatchDeleteModels() { } } -watch(batchPricingProviderId, (value, previousValue) => { - if (previousValue && value !== previousValue) { - selectedBatchManageModelIds.value = new Set() - } -}) - watch([batchPricingProviderOptions, batchManageDialogOpen], ([options, dialogOpen]) => { if (!dialogOpen) return if ( @@ -1828,20 +2193,11 @@ async function editModel(model: GlobalModelResponse) { } function handleModelPricingSynced(model: GlobalModelResponse) { - const updatePricing = (models: GlobalModelResponse[]) => { - const current = models.find(entry => entry.id === model.id) - if (current) { - current.default_tiered_pricing = cloneTieredPricingConfig(model.default_tiered_pricing) - } - } - updatePricing(globalModels.value) - updatePricing(batchManageModels.value) - if (editingModel.value?.id === model.id) { - editingModel.value.default_tiered_pricing = cloneTieredPricingConfig(model.default_tiered_pricing) - } - if (selectedModel.value?.id === model.id) { - selectedModel.value.default_tiered_pricing = cloneTieredPricingConfig(model.default_tiered_pricing) - } + applyGlobalModelUpdate({ + ...model, + default_tiered_pricing: cloneTieredPricingConfig(model.default_tiered_pricing), + config: model.config ? { ...model.config } : model.config, + }) } async function deleteModel(model: GlobalModelResponse) { @@ -1857,6 +2213,7 @@ async function deleteModel(model: GlobalModelResponse) { if (selectedModel.value?.id === model.id) { selectedModel.value = null } + setBatchManageModelSelection(model.id, false) await loadGlobalModels() } catch (err: unknown) { showError(parseApiError(err, '删除失败'), '删除失败') diff --git a/frontend/src/views/admin/__tests__/ModelManagement.pricing-source.spec.ts b/frontend/src/views/admin/__tests__/ModelManagement.pricing-source.spec.ts new file mode 100644 index 000000000..5ced67e33 --- /dev/null +++ b/frontend/src/views/admin/__tests__/ModelManagement.pricing-source.spec.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, nextTick, type App } from 'vue' + +import type { GlobalModelResponse } from '@/api/global-models' +import type { ModelsDevModelItem } from '@/api/models-dev' +import { setI18nLocale } from '@/i18n' +import ModelManagement from '@/views/admin/ModelManagement.vue' + +const apiMocks = vi.hoisted(() => ({ + listGlobalModels: vi.fn(), + getGlobalModel: vi.fn(), + updateGlobalModel: vi.fn(), + deleteGlobalModel: vi.fn(), + batchDeleteGlobalModels: vi.fn(), + batchAssignToProviders: vi.fn(), + getGlobalModelProviders: vi.fn(), + getModelsDevList: vi.fn(), + getProvidersSummary: vi.fn(), +})) + +const interactionMocks = vi.hoisted(() => ({ + confirm: vi.fn(), + confirmDanger: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})) + +vi.mock('@/api/global-models', () => ({ + listGlobalModels: apiMocks.listGlobalModels, + getGlobalModel: apiMocks.getGlobalModel, + updateGlobalModel: apiMocks.updateGlobalModel, + deleteGlobalModel: apiMocks.deleteGlobalModel, + batchDeleteGlobalModels: apiMocks.batchDeleteGlobalModels, + batchAssignToProviders: apiMocks.batchAssignToProviders, + getGlobalModelProviders: apiMocks.getGlobalModelProviders, +})) + +vi.mock('@/api/models-dev', () => ({ + getModelsDevList: apiMocks.getModelsDevList, +})) + +vi.mock('@/api/endpoints/providers', () => ({ + getProvidersSummary: apiMocks.getProvidersSummary, +})) + +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => ({ + confirm: interactionMocks.confirm, + confirmDanger: interactionMocks.confirmDanger, + }), +})) + +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + success: interactionMocks.success, + error: interactionMocks.error, + }), +})) + +vi.mock('@/composables/useClipboard', () => ({ + useClipboard: () => ({ copyToClipboard: vi.fn() }), +})) + +vi.mock('@/features/models/components/GlobalModelFormDialog.vue', async () => { + const { defineComponent } = await import('vue') + return { + default: defineComponent({ + name: 'ChildStub', + setup: () => () => null, + }), + } +}) + +vi.mock('@/features/models/components/ModelDetailDrawer.vue', async () => { + const { defineComponent } = await import('vue') + return { + default: defineComponent({ + name: 'ChildStub', + setup: () => () => null, + }), + } +}) + +vi.mock('@/features/models/components/ExternalModelsAccessControl.vue', async () => { + const { defineComponent } = await import('vue') + return { + default: defineComponent({ + name: 'ChildStub', + setup: () => () => null, + }), + } +}) + +vi.mock('@/features/providers/components/ProviderModelFormDialog.vue', async () => { + const { defineComponent } = await import('vue') + return { + default: defineComponent({ + name: 'ChildStub', + setup: () => () => null, + }), + } +}) + +const pricing = { + tiers: [{ + up_to: null, + input_price_per_1m: 1, + output_price_per_1m: 2, + }], +} + +const onlineModel: ModelsDevModelItem = { + providerId: 'openai', + providerName: 'OpenAI', + modelId: 'test-model', + modelName: 'Test Model', + official: true, + inputPrice: 1, + outputPrice: 2, + tieredPricing: pricing, +} + +let mountedApp: App | null = null +let mountedRoot: HTMLElement | null = null +let persistedModel: GlobalModelResponse + +function cloneModel(model: GlobalModelResponse): GlobalModelResponse { + return JSON.parse(JSON.stringify(model)) as GlobalModelResponse +} + +async function settle() { + for (let index = 0; index < 8; index += 1) { + await Promise.resolve() + await nextTick() + } +} + +function findButton(text: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll('button')] + .find(candidate => candidate.textContent?.trim().includes(text)) + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Missing button containing: ${text}`) + } + return button +} + +function mountView() { + mountedRoot = document.createElement('div') + document.body.appendChild(mountedRoot) + mountedApp = createApp(ModelManagement) + mountedApp.mount(mountedRoot) +} + +beforeEach(() => { + persistedModel = { + id: 'model-1', + name: 'test-model', + display_name: 'Test Model', + is_active: true, + default_tiered_pricing: pricing, + config: { + streaming: true, + models_dev_pricing_source: { + provider_id: 'openai', + provider_name: 'Old OpenAI label', + }, + }, + provider_count: 1, + active_provider_count: 1, + usage_count: 0, + created_at: '2026-09-03T00:00:00Z', + } + + for (const mock of Object.values(apiMocks)) mock.mockReset() + for (const mock of Object.values(interactionMocks)) mock.mockReset() + + apiMocks.listGlobalModels.mockImplementation(async () => ({ + models: [cloneModel(persistedModel)], + total: 1, + })) + apiMocks.updateGlobalModel.mockImplementation(async ( + _modelId: string, + payload: Partial, + ) => { + persistedModel = { ...persistedModel, ...payload } + return cloneModel(persistedModel) + }) + apiMocks.getModelsDevList.mockResolvedValue([onlineModel]) + apiMocks.getGlobalModelProviders.mockResolvedValue({ providers: [] }) + apiMocks.getProvidersSummary.mockResolvedValue({ items: [] }) + interactionMocks.confirm.mockResolvedValue(true) + interactionMocks.confirmDanger.mockResolvedValue(true) +}) + +afterEach(() => { + mountedApp?.unmount() + mountedRoot?.remove() + mountedApp = null + mountedRoot = null + document.body.innerHTML = '' +}) + +describe('ModelManagement pricing-source workflow', () => { + it('keeps list selection in batch management and refreshes stale source metadata when prices match', async () => { + mountView() + await settle() + + const desktopCheckbox = document.body.querySelector( + '[data-testid="model-select-desktop-model-1"]', + ) + const mobileCheckbox = document.body.querySelector( + '[data-testid="model-select-mobile-model-1"]', + ) + expect(desktopCheckbox).not.toBeNull() + expect(mobileCheckbox).not.toBeNull() + + desktopCheckbox!.checked = true + desktopCheckbox!.dispatchEvent(new Event('change', { bubbles: true })) + await settle() + + expect(mobileCheckbox!.checked).toBe(true) + expect(document.body.textContent).toContain('已选 1 个') + findButton('批量操作 (1)').click() + await settle() + + expect(document.body.textContent).toContain('已选择 1 个') + expect(document.body.textContent).toContain('来源待保存') + findButton('同步价格与来源 (1)').click() + await settle() + + expect(interactionMocks.confirm).toHaveBeenCalledOnce() + expect(apiMocks.updateGlobalModel).toHaveBeenCalledWith('model-1', { + default_tiered_pricing: pricing, + config: { + streaming: true, + models_dev_pricing_source: { + provider_id: 'openai', + provider_name: 'OpenAI', + }, + }, + }) + expect(persistedModel.config).toEqual({ + streaming: true, + models_dev_pricing_source: { + provider_id: 'openai', + provider_name: 'OpenAI', + }, + }) + }) + + it('renders the new source and selection controls in English', async () => { + mountView() + await settle() + setI18nLocale('en-US') + await settle() + + expect(document.body.textContent).toContain('Price source') + expect(document.body.textContent).toContain('Batch manage') + expect(document.body.querySelector( + '[aria-label="Select model Test Model"]', + )).not.toBeNull() + expect(document.body.querySelector( + '[data-testid="model-pricing-source-model-1"]', + )?.getAttribute('aria-label')).toContain('Current source: Old OpenAI label') + }) + + it('migrates a legacy browser-only source into the model database config', async () => { + persistedModel.config = { streaming: true } + localStorage.setItem('aether:models-dev-pricing-sources:v1', JSON.stringify({ + version: 1, + models: { + 'model-1': { + provider_id: 'openai', + provider_name: 'OpenAI', + }, + }, + })) + + mountView() + await settle() + + expect(apiMocks.updateGlobalModel).toHaveBeenCalledWith('model-1', { + config: { + streaming: true, + models_dev_pricing_source: { + provider_id: 'openai', + provider_name: 'OpenAI', + }, + }, + }) + expect(persistedModel.config).toHaveProperty( + 'models_dev_pricing_source.provider_id', + 'openai', + ) + }) +})
+ {{ t('models.pricingSource.pendingDatabase') }} +