feat(frontend): persist model pricing sources

This commit is contained in:
ZheFox
2026-09-03 20:49:42 +08:00
parent 89b57464d2
commit 7b612b8b5a
8 changed files with 1288 additions and 146 deletions
@@ -408,10 +408,14 @@
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="sm"
class="h-8 w-8 shrink-0" class="h-8 min-w-0 max-w-56 shrink-0 gap-1.5 px-2.5"
:disabled="syncingOnlinePricing || submitting" :disabled="syncingOnlinePricing || submitting"
:title="syncingOnlinePricing ? '正在同步在线价格' : '同步最新在线价格'" :title="syncingOnlinePricing
? t('models.pricingSource.syncingTitle')
: currentOnlinePricingSource
? t('models.pricingSource.editCurrentTitle', { provider: currentOnlinePricingSource.provider_name })
: t('models.pricingSource.editChooseTitle')"
aria-label="同步最新在线价格" aria-label="同步最新在线价格"
data-testid="sync-online-pricing" data-testid="sync-online-pricing"
@click="syncOnlinePricing" @click="syncOnlinePricing"
@@ -420,6 +424,11 @@
class="h-4 w-4" class="h-4 w-4"
:class="syncingOnlinePricing ? 'animate-spin' : ''" :class="syncingOnlinePricing ? 'animate-spin' : ''"
/> />
<span class="truncate text-xs">
{{ currentOnlinePricingSource
? t('models.pricingSource.buttonCurrent', { provider: currentOnlinePricingSource.provider_name })
: t('models.pricingSource.choose') }}
</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
@@ -771,6 +780,7 @@ import {
} from '@/components/ui' } from '@/components/ui'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog' import { useFormDialog } from '@/composables/useFormDialog'
import { useI18n } from '@/i18n'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form' import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
@@ -797,7 +807,12 @@ import {
tieredPricingConfigsEqual, tieredPricingConfigsEqual,
} from './global-model-form-helpers' } from './global-model-form-helpers'
import { tieredPricingHasImageOutputPricing } from '../utils/tiered-pricing' import { tieredPricingHasImageOutputPricing } from '../utils/tiered-pricing'
import { useModelsDevPricingSources } from '../composables/useModelsDevPricingSources' import {
getModelsDevPricingSourceFromConfig,
modelsDevPricingSourcesEqual,
useModelsDevPricingSources,
withModelsDevPricingSource,
} from '../composables/useModelsDevPricingSources'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
@@ -812,6 +827,7 @@ const emit = defineEmits<{
}>() }>()
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const { t } = useI18n()
const { getSource, setSource } = useModelsDevPricingSources() const { getSource, setSource } = useModelsDevPricingSources()
const submitting = ref(false) const submitting = ref(false)
const syncingOnlinePricing = ref(false) const syncingOnlinePricing = ref(false)
@@ -842,6 +858,9 @@ const selectedOnlinePricingCandidate = computed(() => (
candidate.providerId === selectedOnlinePricingProviderId.value candidate.providerId === selectedOnlinePricingProviderId.value
)) ?? null )) ?? null
)) ))
const currentOnlinePricingSource = computed(() => (
props.model ? getSource(props.model.id, props.model.config) : null
))
const firstSyncableOnlinePricingProviderId = computed(() => ( const firstSyncableOnlinePricingProviderId = computed(() => (
onlinePricingCandidates.value.find(isOnlinePricingCandidateSyncable)?.providerId ?? '' onlinePricingCandidates.value.find(isOnlinePricingCandidateSyncable)?.providerId ?? ''
)) ))
@@ -1407,7 +1426,7 @@ function resolveOnlinePricingModel(
): ModelsDevModelItem | null { ): ModelsDevModelItem | null {
const modelId = normalizeModelId(model.name) const modelId = normalizeModelId(model.name)
const transientSource = editingOnlinePricingSource.value 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 const preferredProviderId = transientSource?.model_id && normalizeModelId(transientSource.model_id) === modelId
? transientSource.provider_id ? transientSource.provider_id
: storedSource?.provider_id : storedSource?.provider_id
@@ -1530,28 +1549,44 @@ async function applyOnlinePricingModel(onlineModel: ModelsDevModelItem) {
props.model.default_tiered_pricing, props.model.default_tiered_pricing,
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 let syncedModel: GlobalModelResponse
if (pricingChanged) { if (pricingChanged || sourceChanged) {
syncedModel = await updateGlobalModel(props.model.id, { const updatedModel = await updateGlobalModel(props.model.id, {
default_tiered_pricing: pricing, default_tiered_pricing: pricing,
config: nextConfig,
}) })
syncedModel = {
...updatedModel,
default_tiered_pricing: pricing,
config: nextConfig,
}
} else { } else {
syncedModel = { syncedModel = {
...props.model, ...props.model,
default_tiered_pricing: pricing, default_tiered_pricing: pricing,
config: nextConfig,
} }
} }
tieredPricing.value = cloneTieredPricingConfig(pricing) tieredPricing.value = cloneTieredPricingConfig(pricing)
form.value.config = { ...nextConfig }
billingMode.value = 'token' billingMode.value = 'token'
setSource(props.model.id, { setSource(props.model.id, pricingSource)
provider_id: onlineModel.providerId,
provider_name: onlineModel.providerName,
})
emit('pricingSynced', syncedModel) emit('pricingSynced', syncedModel)
success( success(
pricingChanged pricingChanged
? `已同步 ${onlineModel.providerName} 的最新价格` ? `已同步 ${onlineModel.providerName} 的最新价格`
: `当前价格已是 ${onlineModel.providerName} 的最新价格`, : sourceChanged
? t('models.pricingSource.savedNoPriceChange', { provider: onlineModel.providerName })
: `当前价格已是 ${onlineModel.providerName} 的最新价格`,
) )
} }
@@ -1725,6 +1760,12 @@ async function handleSubmit() {
success('模型更新成功') success('模型更新成功')
} else { } else {
const createData = buildGlobalModelCreatePayload(form.value, finalTieredPricing) 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) const createdModel = await createGlobalModel(createData)
existingModelsCache.value.unshift(createdModel) existingModelsCache.value.unshift(createdModel)
if (selectedModel.value) { if (selectedModel.value) {
@@ -0,0 +1,146 @@
<template>
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-1">
<Select
:model-value="source?.provider_id"
:disabled="syncing"
@update:open="emit('open', $event)"
@update:model-value="emit('select', $event)"
>
<SelectTrigger
class="h-8 min-w-0 flex-1 px-2 text-xs"
:title="source
? t('models.pricingSource.currentTitle', { provider: source.provider_name })
: t('models.pricingSource.chooseTitle')"
:aria-label="source
? t('models.pricingSource.currentTitle', { provider: source.provider_name })
: t('models.pricingSource.chooseTitle')"
:data-testid="`model-pricing-source-${modelId}`"
>
<Loader2
v-if="syncing"
class="mr-1 h-3 w-3 shrink-0 animate-spin"
/>
<SelectValue :placeholder="loading ? t('models.pricingSource.loading') : t('models.pricingSource.choose')">
<span class="truncate">{{ source?.provider_name || t('models.pricingSource.choose') }}</span>
</SelectValue>
</SelectTrigger>
<SelectContent
class="w-72"
align="end"
>
<SelectItem
v-if="loading && candidates.length === 0"
:value="`__loading__:${modelId}`"
disabled
>
{{ t('models.pricingSource.loadingOptions') }}
</SelectItem>
<SelectItem
v-for="candidate in candidates"
:key="candidate.providerId"
:value="candidate.providerId"
:disabled="!isCandidateSyncable(candidate)"
:text-value="`${candidate.providerName} ${candidate.providerId}`"
>
<div class="flex min-w-0 items-center justify-between gap-3">
<div class="min-w-0">
<div class="truncate text-xs font-medium">
{{ candidate.providerName }}
</div>
<div class="truncate font-mono text-[10px] text-muted-foreground">
{{ candidate.providerId }}
</div>
</div>
<div class="shrink-0 text-right text-[10px] text-muted-foreground">
<template v-if="isCandidateSyncable(candidate)">
<div>{{ t('models.pricingSource.inputPrice', { price: formatPrice(candidate.inputPrice) }) }}</div>
<div>{{ t('models.pricingSource.outputPrice', { price: formatPrice(candidate.outputPrice) }) }}</div>
</template>
<span v-else>{{ getUnavailableReason(candidate) }}</span>
</div>
</div>
</SelectItem>
<SelectItem
v-if="!loading && candidates.length === 0"
:value="`__empty__:${modelId}`"
disabled
>
{{ t('models.pricingSource.catalogEmpty') }}
</SelectItem>
</SelectContent>
</Select>
<Button
v-if="source"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
:disabled="syncing"
:title="t('models.pricingSource.resyncTitle')"
:aria-label="t('models.pricingSource.resyncTitle')"
:data-testid="`model-pricing-source-resync-${modelId}`"
@click="emit('resync')"
>
<RefreshCw
class="h-3.5 w-3.5"
:class="syncing ? 'animate-spin' : ''"
/>
</Button>
</div>
<p
v-if="localOnly"
class="mt-1 text-[10px] text-amber-600 dark:text-amber-400"
>
{{ t('models.pricingSource.pendingDatabase') }}
</p>
</div>
</template>
<script setup lang="ts">
import { Loader2, RefreshCw } from 'lucide-vue-next'
import type { ModelsDevModelItem } from '@/api/models-dev'
import { useI18n } from '@/i18n'
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import type { ModelsDevPricingSource } from '../composables/useModelsDevPricingSources'
defineProps<{
modelId: string
source: ModelsDevPricingSource | null
candidates: ModelsDevModelItem[]
loading: boolean
syncing: boolean
localOnly?: boolean
}>()
const emit = defineEmits<{
open: [value: boolean]
select: [providerId: string]
resync: []
}>()
const { t } = useI18n()
function isCandidateSyncable(candidate: ModelsDevModelItem): boolean {
return !candidate.pricingUnsupportedFields?.length && !!candidate.tieredPricing?.tiers?.length
}
function getUnavailableReason(candidate: ModelsDevModelItem): string {
if (candidate.pricingUnsupportedFields?.length) return t('models.pricingSource.incompatible')
return t('models.pricingSource.noTokenPrice')
}
function formatPrice(value?: number): string {
if (value === undefined) return '-'
if (value === 0) return '0'
const precision = value < 0.01 ? 4 : value < 1 ? 3 : 2
return value.toFixed(precision).replace(/\.?0+$/, '')
}
</script>
@@ -306,6 +306,10 @@ describe('GlobalModelFormDialog preset replacement', () => {
family: 'fresh-family', family: 'fresh-family',
input_modalities: ['text'], input_modalities: ['text'],
output_modalities: ['text'], output_modalities: ['text'],
models_dev_pricing_source: {
provider_id: 'openai',
provider_name: 'OpenAI',
},
}, },
default_tiered_pricing: { default_tiered_pricing: {
tiers: [ tiers: [
@@ -477,9 +481,17 @@ describe('GlobalModelFormDialog preset replacement', () => {
it('refreshes and applies the latest online price from the edit dialog', async () => { it('refreshes and applies the latest online price from the edit dialog', async () => {
const existingStaleModel = buildExistingStaleModel() const existingStaleModel = buildExistingStaleModel()
const nextConfig = {
...existingStaleModel.config,
models_dev_pricing_source: {
provider_id: stalePreset.providerId,
provider_name: stalePreset.providerName,
},
}
const syncedModel = { const syncedModel = {
...existingStaleModel, ...existingStaleModel,
default_tiered_pricing: stalePreset.tieredPricing!, default_tiered_pricing: stalePreset.tieredPricing!,
config: nextConfig,
} }
globalModelMocks.updateGlobalModel.mockResolvedValue(syncedModel) globalModelMocks.updateGlobalModel.mockResolvedValue(syncedModel)
globalModelMocks.listGlobalModels.mockResolvedValue({ globalModelMocks.listGlobalModels.mockResolvedValue({
@@ -495,7 +507,7 @@ describe('GlobalModelFormDialog preset replacement', () => {
'[data-testid="sync-online-pricing"]', '[data-testid="sync-online-pricing"]',
) )
if (!syncButton) throw new Error('Missing online pricing sync button') if (!syncButton) throw new Error('Missing online pricing sync button')
expect(syncButton.title).toBe('同步最新在线价格') expect(syncButton.title).toBe('选择并同步在线价格来源')
expect(syncButton.getAttribute('aria-label')).toBe('同步最新在线价格') expect(syncButton.getAttribute('aria-label')).toBe('同步最新在线价格')
syncButton.click() syncButton.click()
@@ -505,7 +517,10 @@ describe('GlobalModelFormDialog preset replacement', () => {
expect(modelsDevMocks.refreshModelsDevList).toHaveBeenCalledWith(false) expect(modelsDevMocks.refreshModelsDevList).toHaveBeenCalledWith(false)
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith( expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
existingStaleModel.id, existingStaleModel.id,
{ default_tiered_pricing: stalePreset.tieredPricing }, {
default_tiered_pricing: stalePreset.tieredPricing,
config: nextConfig,
},
) )
expect(pricingSynced).toHaveBeenCalledWith(syncedModel) expect(pricingSynced).toHaveBeenCalledWith(syncedModel)
expect(document.body.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value) expect(document.body.querySelector<HTMLInputElement>('[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<HTMLButtonElement>(
'[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 () => { it('offers a provider choice when the remembered source is unavailable', async () => {
const existingStaleModel = buildExistingStaleModel() const existingStaleModel = buildExistingStaleModel()
const nextConfig = {
...existingStaleModel.config,
models_dev_pricing_source: {
provider_id: alternateStalePreset.providerId,
provider_name: alternateStalePreset.providerName,
},
}
const syncedModel = { const syncedModel = {
...existingStaleModel, ...existingStaleModel,
default_tiered_pricing: alternateStalePreset.tieredPricing!, default_tiered_pricing: alternateStalePreset.tieredPricing!,
config: nextConfig,
} }
modelsDevMocks.refreshModelsDevList.mockResolvedValue([ modelsDevMocks.refreshModelsDevList.mockResolvedValue([
unavailableStalePreset, unavailableStalePreset,
@@ -578,7 +644,10 @@ describe('GlobalModelFormDialog preset replacement', () => {
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith( expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
existingStaleModel.id, existingStaleModel.id,
{ default_tiered_pricing: alternateStalePreset.tieredPricing }, {
default_tiered_pricing: alternateStalePreset.tieredPricing,
config: nextConfig,
},
) )
expect(pricingSynced).toHaveBeenCalledWith(syncedModel) expect(pricingSynced).toHaveBeenCalledWith(syncedModel)
expect(document.body.textContent).not.toContain('选择在线价格来源') expect(document.body.textContent).not.toContain('选择在线价格来源')
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it } from 'vitest' 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 STORAGE_KEY = 'aether:models-dev-pricing-sources:v1'
const LEGACY_STORAGE_KEY = 'aether:models-dev-pricing-preferences: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', () => { it('migrates the previous provider record without retaining its automatic preference key', () => {
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({ localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({
version: 1, version: 1,
@@ -66,3 +89,38 @@ describe('useModelsDevPricingSources', () => {
expect(getSource('model-1')).toBeNull() expect(getSource('model-1')).toBeNull()
}) })
}) })
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)
})
})
@@ -5,6 +5,8 @@ export interface ModelsDevPricingSource {
provider_name: string provider_name: string
} }
export const MODELS_DEV_PRICING_SOURCE_CONFIG_KEY = 'models_dev_pricing_source'
interface StoredModelsDevPricingSources { interface StoredModelsDevPricingSources {
version: 1 version: 1
models: Record<string, ModelsDevPricingSource> models: Record<string, ModelsDevPricingSource>
@@ -14,6 +16,50 @@ const STORAGE_KEY = 'aether:models-dev-pricing-sources:v1'
const LEGACY_STORAGE_KEY = 'aether:models-dev-pricing-preferences:v1' const LEGACY_STORAGE_KEY = 'aether:models-dev-pricing-preferences:v1'
const sources = ref<Record<string, ModelsDevPricingSource>>({}) const sources = ref<Record<string, ModelsDevPricingSource>>({})
function normalizePricingSource(value: unknown): ModelsDevPricingSource | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const source = value as Partial<ModelsDevPricingSource>
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<string, unknown> | 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<string, unknown> | null | undefined,
source: ModelsDevPricingSource,
): Record<string, unknown> {
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<string, ModelsDevPricingSource> | null { function parseStoredSources(key: string): Record<string, ModelsDevPricingSource> | null {
try { try {
const stored = JSON.parse(localStorage.getItem(key) || 'null') as unknown const stored = JSON.parse(localStorage.getItem(key) || 'null') as unknown
@@ -23,19 +69,8 @@ function parseStoredSources(key: string): Record<string, ModelsDevPricingSource>
const validSources: Record<string, ModelsDevPricingSource> = {} const validSources: Record<string, ModelsDevPricingSource> = {}
for (const [modelId, value] of Object.entries(document.models)) { for (const [modelId, value] of Object.entries(document.models)) {
if (!value || typeof value !== 'object') continue const source = normalizePricingSource(value)
const source = value as Partial<ModelsDevPricingSource> if (source) validSources[modelId] = source
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,
}
}
} }
return validSources return validSources
} catch { } catch {
@@ -75,14 +110,23 @@ function readStoredSources(): Record<string, ModelsDevPricingSource> {
export function useModelsDevPricingSources() { export function useModelsDevPricingSources() {
sources.value = readStoredSources() sources.value = readStoredSources()
function getSource(modelId: string): ModelsDevPricingSource | null { function getLocalSource(modelId: string): ModelsDevPricingSource | null {
return sources.value[modelId] ?? null return sources.value[modelId] ?? null
} }
function getSource(
modelId: string,
config?: Record<string, unknown> | null,
): ModelsDevPricingSource | null {
return getModelsDevPricingSourceFromConfig(config) ?? getLocalSource(modelId)
}
function setSource(modelId: string, source: ModelsDevPricingSource) { function setSource(modelId: string, source: ModelsDevPricingSource) {
const normalizedSource = normalizePricingSource(source)
if (!normalizedSource) return
const nextSources = { const nextSources = {
...sources.value, ...sources.value,
[modelId]: source, [modelId]: normalizedSource,
} }
sources.value = nextSources sources.value = nextSources
writeStoredSources(nextSources) writeStoredSources(nextSources)
@@ -90,6 +134,7 @@ export function useModelsDevPricingSources() {
return { return {
getSource, getSource,
getLocalSource,
setSource, setSource,
} }
} }
+130
View File
@@ -323,6 +323,71 @@ export const messages = {
'models.externalCatalog.saved': '外部模型目录代理节点已保存', 'models.externalCatalog.saved': '外部模型目录代理节点已保存',
'models.externalCatalog.saveFailed': '保存外部模型目录代理节点失败', 'models.externalCatalog.saveFailed': '保存外部模型目录代理节点失败',
'models.externalCatalog.loadFailed': '加载外部模型目录代理节点失败', '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.routing': '调度策略',
'nav.pool': '号池管理', 'nav.pool': '号池管理',
'nav.standaloneKeys': '独立密钥', 'nav.standaloneKeys': '独立密钥',
@@ -675,6 +740,71 @@ export const messages = {
'models.externalCatalog.saved': 'External model catalog proxy saved', 'models.externalCatalog.saved': 'External model catalog proxy saved',
'models.externalCatalog.saveFailed': 'Failed to save external model catalog proxy', 'models.externalCatalog.saveFailed': 'Failed to save external model catalog proxy',
'models.externalCatalog.loadFailed': 'Failed to load 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.routing': 'Routing',
'nav.pool': 'Pool', 'nav.pool': 'Pool',
'nav.standaloneKeys': 'Standalone keys', 'nav.standaloneKeys': 'Standalone keys',
+470 -113
View File
@@ -8,9 +8,18 @@
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60"> <div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<!-- 左侧:标题 --> <!-- 左侧:标题 -->
<h3 class="text-sm sm:text-base font-semibold shrink-0"> <div class="flex min-w-0 items-baseline gap-2">
模型管理 <h3 class="text-sm sm:text-base font-semibold shrink-0">
</h3> 模型管理
</h3>
<span
v-if="selectedBatchManageModelIds.size > 0"
class="text-xs font-medium text-primary"
aria-live="polite"
>
{{ t('models.management.selectedCount', { count: selectedBatchManageModelIds.size }) }}
</span>
</div>
<!-- 右侧:操作区 --> <!-- 右侧:操作区 -->
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -28,13 +37,18 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<Button <Button
variant="ghost" variant="outline"
size="icon" size="sm"
class="h-8 w-8" class="h-8 gap-1.5 px-2.5"
title="快速筛选与批量操作" :title="selectedBatchManageModelIds.size > 0
? t('models.management.manageSelectedTitle', { count: selectedBatchManageModelIds.size })
: t('models.management.batch.title')"
@click="openBatchManageDialog" @click="openBatchManageDialog"
> >
<ListChecks class="w-3.5 h-3.5" /> <ListChecks class="w-3.5 h-3.5" />
<span>{{ selectedBatchManageModelIds.size > 0
? t('models.management.batchButtonSelected', { count: selectedBatchManageModelIds.size })
: t('models.management.batchButton') }}</span>
</Button> </Button>
<ExternalModelsAccessControl /> <ExternalModelsAccessControl />
<Button <Button
@@ -57,12 +71,26 @@
<Table class="hidden xl:table"> <Table class="hidden xl:table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-[240px]"> <TableHead class="w-[250px]">
模型名称 <div class="flex items-center gap-2">
<Checkbox
class="h-3.5 w-3.5 shrink-0"
:checked="isCurrentModelPageFullySelected"
:indeterminate="isCurrentModelPagePartiallySelected"
:disabled="paginatedGlobalModels.length === 0 || loading"
:aria-label="t('models.management.selectCurrentPage')"
data-testid="model-select-page-desktop"
@update:checked="toggleCurrentModelPageSelection($event === true)"
/>
<span>模型名称</span>
</div>
</TableHead> </TableHead>
<TableHead class="w-[160px] text-center"> <TableHead class="w-[160px] text-center">
价格 ($/M) 价格 ($/M)
</TableHead> </TableHead>
<TableHead class="w-[170px] text-center">
{{ t('models.pricingSource.label') }}
</TableHead>
<TableHead class="w-[80px] text-center"> <TableHead class="w-[80px] text-center">
提供商 提供商
</TableHead> </TableHead>
@@ -80,7 +108,7 @@
<TableBody> <TableBody>
<TableRow v-if="loading"> <TableRow v-if="loading">
<TableCell <TableCell
colspan="6" colspan="7"
class="text-center py-8" class="text-center py-8"
> >
<Loader2 class="w-6 h-6 animate-spin mx-auto" /> <Loader2 class="w-6 h-6 animate-spin mx-auto" />
@@ -88,7 +116,7 @@
</TableRow> </TableRow>
<TableRow v-else-if="filteredGlobalModels.length === 0"> <TableRow v-else-if="filteredGlobalModels.length === 0">
<TableCell <TableCell
colspan="6" colspan="7"
class="text-center py-8 text-muted-foreground" class="text-center py-8 text-muted-foreground"
> >
没有找到匹配的模型 没有找到匹配的模型
@@ -99,23 +127,34 @@
v-for="model in paginatedGlobalModels" v-for="model in paginatedGlobalModels"
:key="model.id" :key="model.id"
class="cursor-pointer hover:bg-muted/50 group" class="cursor-pointer hover:bg-muted/50 group"
:class="selectedBatchManageModelIds.has(model.id) ? 'bg-primary/5' : ''"
@mousedown="handleMouseDown" @mousedown="handleMouseDown"
@click="handleRowClick($event, model)" @click="handleRowClick($event, model)"
> >
<TableCell> <TableCell>
<div> <div class="flex min-w-0 items-start gap-2">
<div class="font-medium"> <Checkbox
{{ model.display_name }} class="mt-0.5 h-3.5 w-3.5 shrink-0"
</div> :checked="selectedBatchManageModelIds.has(model.id)"
<div class="text-xs text-muted-foreground flex items-center gap-1"> :aria-label="t('models.management.selectModel', { name: model.display_name || model.name })"
<span>{{ model.name }}</span> :data-testid="`model-select-desktop-${model.id}`"
<button @click.stop
class="p-0.5 rounded hover:bg-muted transition-colors" @update:checked="setBatchManageModelSelection(model.id, $event === true)"
title="复制模型 ID" />
@click.stop="copyToClipboard(model.name)" <div class="min-w-0 flex-1">
> <div class="font-medium truncate">
<Copy class="w-3 h-3" /> {{ model.display_name }}
</button> </div>
<div class="text-xs text-muted-foreground flex items-center gap-1">
<span class="truncate">{{ model.name }}</span>
<button
class="p-0.5 rounded hover:bg-muted transition-colors shrink-0"
title="复制模型 ID"
@click.stop="copyToClipboard(model.name)"
>
<Copy class="w-3 h-3" />
</button>
</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
@@ -157,6 +196,24 @@
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell
class="text-center"
@mousedown.stop
@click.stop
>
<GlobalModelPricingSourceSelect
class="mx-auto max-w-[165px]"
:model-id="model.id"
:source="getModelPricingSource(model)"
:candidates="getModelPricingCandidates(model)"
:loading="batchManageOnlineLoading"
:syncing="isModelPricingSourceSyncing(model.id)"
:local-only="isModelPricingSourceLocalOnly(model)"
@open="handleModelPricingSourceOpen(model, $event)"
@select="syncModelPricingSource(model, $event)"
@resync="resyncModelPricingSource(model)"
/>
</TableCell>
<TableCell class="text-center"> <TableCell class="text-center">
<Badge variant="secondary"> <Badge variant="secondary">
{{ model.active_provider_count || 0 }}/{{ model.provider_count || 0 }} {{ model.active_provider_count || 0 }}/{{ model.provider_count || 0 }}
@@ -224,28 +281,39 @@
v-for="model in paginatedGlobalModels" v-for="model in paginatedGlobalModels"
:key="model.id" :key="model.id"
class="p-4 space-y-3 hover:bg-muted/50 cursor-pointer transition-colors" 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)" @click="selectModel(model)"
> >
<!-- 第一行:名称 + 状态 + 操作 --> <!-- 第一行:名称 + 状态 + 操作 -->
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0"> <div class="flex min-w-0 flex-1 items-start gap-2">
<div class="flex items-center gap-2"> <Checkbox
<span class="font-medium truncate">{{ model.display_name }}</span> class="mt-0.5 h-4 w-4 shrink-0"
<Badge :checked="selectedBatchManageModelIds.has(model.id)"
:variant="model.is_active ? 'default' : 'secondary'" :aria-label="t('models.management.selectModel', { name: model.display_name || model.name })"
class="text-xs shrink-0" :data-testid="`model-select-mobile-${model.id}`"
> @click.stop
{{ model.is_active ? '活跃' : '停用' }} @update:checked="setBatchManageModelSelection(model.id, $event === true)"
</Badge> />
</div> <div class="min-w-0 flex-1">
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5"> <div class="flex items-center gap-2">
<span class="font-mono truncate">{{ model.name }}</span> <span class="font-medium truncate">{{ model.display_name }}</span>
<button <Badge
class="p-0.5 rounded hover:bg-muted transition-colors shrink-0" :variant="model.is_active ? 'default' : 'secondary'"
@click.stop="copyToClipboard(model.name)" class="text-xs shrink-0"
> >
<Copy class="w-3 h-3" /> {{ model.is_active ? '活跃' : '停用' }}
</button> </Badge>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<span class="font-mono truncate">{{ model.name }}</span>
<button
class="p-0.5 rounded hover:bg-muted transition-colors shrink-0"
@click.stop="copyToClipboard(model.name)"
>
<Copy class="w-3 h-3" />
</button>
</div>
</div> </div>
</div> </div>
<div <div
@@ -290,6 +358,26 @@
${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}/${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }} ${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}/${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
</span> </span>
</div> </div>
<div
class="flex items-center gap-2"
@mousedown.stop
@click.stop
>
<span class="shrink-0 text-xs text-muted-foreground">{{ t('models.pricingSource.label') }}</span>
<GlobalModelPricingSourceSelect
class="min-w-0 flex-1"
:model-id="model.id"
:source="getModelPricingSource(model)"
:candidates="getModelPricingCandidates(model)"
:loading="batchManageOnlineLoading"
:syncing="isModelPricingSourceSyncing(model.id)"
:local-only="isModelPricingSourceLocalOnly(model)"
@open="handleModelPricingSourceOpen(model, $event)"
@select="syncModelPricingSource(model, $event)"
@resync="resyncModelPricingSource(model)"
/>
</div>
</div> </div>
</div> </div>
@@ -472,8 +560,8 @@
<!-- 批量管理全局模型对话框 --> <!-- 批量管理全局模型对话框 -->
<Dialog <Dialog
:model-value="batchManageDialogOpen" :model-value="batchManageDialogOpen"
title="快速筛选与批量操作" :title="t('models.management.batch.title')"
description="默认按每个模型上次选择的在线来源同步,也可手动指定统一来源" :description="t('models.management.batch.description')"
:icon="ListChecks" :icon="ListChecks"
size="2xl" size="2xl"
@update:model-value="batchManageDialogOpen = $event" @update:model-value="batchManageDialogOpen = $event"
@@ -495,11 +583,11 @@
:disabled="batchManageOnlineLoading" :disabled="batchManageOnlineLoading"
> >
<SelectTrigger class="h-9 text-xs"> <SelectTrigger class="h-9 text-xs">
<SelectValue placeholder="选择在线价格来源" /> <SelectValue :placeholder="t('models.management.batch.providerPlaceholder')" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem :value="REMEMBERED_PRICING_PROVIDER_ID"> <SelectItem :value="REMEMBERED_PRICING_PROVIDER_ID">
上次选择(按模型) {{ t('models.management.batch.rememberedProvider') }}
</SelectItem> </SelectItem>
<SelectItem <SelectItem
v-for="provider in batchPricingProviderOptions" v-for="provider in batchPricingProviderOptions"
@@ -634,7 +722,7 @@
<div class="flex w-full min-w-0 flex-col gap-2 sm:flex-row lg:w-auto lg:shrink-0"> <div class="flex w-full min-w-0 flex-col gap-2 sm:flex-row lg:w-auto lg:shrink-0">
<Button <Button
class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none" class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none"
:disabled="selectedBatchPriceSyncPlan.syncable.length === 0 || submittingBatchManage" :disabled="selectedBatchPriceSyncEntries.length === 0 || submittingBatchManage"
@click="confirmBatchSyncPrices" @click="confirmBatchSyncPrices"
> >
<Loader2 <Loader2
@@ -645,7 +733,9 @@
v-else v-else
class="w-4 h-4 mr-1" class="w-4 h-4 mr-1"
/> />
{{ batchManageAction === 'sync-prices' ? '同步中...' : `同步在线价格 (${selectedBatchPriceSyncPlan.syncable.length})` }} {{ batchManageAction === 'sync-prices'
? t('models.management.batch.syncing')
: t('models.management.batch.syncButton', { count: selectedBatchPriceSyncEntries.length }) }}
</Button> </Button>
<Button <Button
class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none" class="w-full whitespace-nowrap sm:flex-1 lg:w-auto lg:flex-none"
@@ -692,6 +782,7 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import ModelDetailDrawer from '@/features/models/components/ModelDetailDrawer.vue' import ModelDetailDrawer from '@/features/models/components/ModelDetailDrawer.vue'
import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue' import GlobalModelFormDialog from '@/features/models/components/GlobalModelFormDialog.vue'
import GlobalModelPricingSourceSelect from '@/features/models/components/GlobalModelPricingSourceSelect.vue'
import ExternalModelsAccessControl from '@/features/models/components/ExternalModelsAccessControl.vue' import ExternalModelsAccessControl from '@/features/models/components/ExternalModelsAccessControl.vue'
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue' import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
import type { Model } from '@/api/endpoints' import type { Model } from '@/api/endpoints'
@@ -699,11 +790,13 @@ import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { useRowClick } from '@/composables/useRowClick' import { useRowClick } from '@/composables/useRowClick'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { sortResolutionEntries } from '@/utils/form' import { sortResolutionEntries } from '@/utils/form'
import { import {
Button, Button,
Card, Card,
Checkbox,
Input, Input,
Table, Table,
TableHeader, TableHeader,
@@ -738,8 +831,14 @@ import { getModelsDevList, type ModelsDevModelItem } from '@/api/models-dev'
import { import {
buildGlobalModelPriceSyncPlan, buildGlobalModelPriceSyncPlan,
cloneTieredPricingConfig, cloneTieredPricingConfig,
type GlobalModelPriceSyncEntry,
} from '@/features/models/components/global-model-form-helpers' } from '@/features/models/components/global-model-form-helpers'
import { useModelsDevPricingSources } from '@/features/models/composables/useModelsDevPricingSources' import {
getModelsDevPricingSourceFromConfig,
modelsDevPricingSourcesEqual,
useModelsDevPricingSources,
withModelsDevPricingSource,
} from '@/features/models/composables/useModelsDevPricingSources'
interface ModelProviderDisplay { interface ModelProviderDisplay {
@@ -765,8 +864,13 @@ interface ModelProviderDisplay {
} }
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const { t, locale } = useI18n()
const { copyToClipboard } = useClipboard() const { copyToClipboard } = useClipboard()
const { getSource: getModelsDevPricingSource, setSource: setModelsDevPricingSource } = useModelsDevPricingSources() const {
getSource: getModelsDevPricingSource,
getLocalSource: getLocalModelsDevPricingSource,
setSource: setModelsDevPricingSource,
} = useModelsDevPricingSources()
const REMEMBERED_PRICING_PROVIDER_ID = '__remembered__' const REMEMBERED_PRICING_PROVIDER_ID = '__remembered__'
// 状态 // 状态
@@ -785,11 +889,14 @@ const batchManageModels = ref<GlobalModelResponse[]>([])
const batchManageLoading = ref(false) const batchManageLoading = ref(false)
const batchManageOnlineModels = ref<ModelsDevModelItem[]>([]) const batchManageOnlineModels = ref<ModelsDevModelItem[]>([])
const batchManageOnlineLoading = ref(false) const batchManageOnlineLoading = ref(false)
const modelPricingSourceSyncingIds = ref<Set<string>>(new Set())
const GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE = 1000 const GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE = 1000
let globalModelsRequestId = 0 let globalModelsRequestId = 0
let modelSelectionRequestId = 0 let modelSelectionRequestId = 0
let modelProvidersRequestId = 0 let modelProvidersRequestId = 0
let batchManageModelsRequestId = 0 let batchManageModelsRequestId = 0
let batchManageOnlineModelsRequest: Promise<void> | null = null
const pricingSourceMigrationAttemptedIds = new Set<string>()
let providerOptionsRequest: Promise<void> | null = null let providerOptionsRequest: Promise<void> | null = null
const GLOBAL_MODELS_LIST_CACHE_TTL_MS = 10 * 1000 const GLOBAL_MODELS_LIST_CACHE_TTL_MS = 10 * 1000
@@ -1097,6 +1204,32 @@ const filteredGlobalModels = computed(() => {
// 模型目录分页计算 // 模型目录分页计算
const paginatedGlobalModels = computed(() => filteredGlobalModels.value) 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, () => { watch(searchQuery, () => {
catalogCurrentPage.value = 1 catalogCurrentPage.value = 1
@@ -1133,6 +1266,7 @@ async function loadGlobalModels(options: { cacheTtlMs?: number } = {}) {
globalModels.value = pageModels globalModels.value = pageModels
totalGlobalModels.value = total totalGlobalModels.value = total
migrateLegacyModelsDevPricingSources(pageModels)
} catch (err: unknown) { } catch (err: unknown) {
if (requestId !== globalModelsRequestId) return if (requestId !== globalModelsRequestId) return
log.error('加载模型失败:', err) log.error('加载模型失败:', err)
@@ -1175,6 +1309,11 @@ async function loadBatchManageModels() {
if (requestId !== batchManageModelsRequestId) return if (requestId !== batchManageModelsRequestId) return
batchManageModels.value = allModels 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) { } catch (err: unknown) {
if (requestId !== batchManageModelsRequestId) return if (requestId !== batchManageModelsRequestId) return
log.error('加载批量管理模型失败:', err) log.error('加载批量管理模型失败:', err)
@@ -1369,6 +1508,164 @@ const filteredBatchManageModels = computed(() => {
}) })
}) })
const onlinePricingCandidatesByModelName = computed(() => {
const candidatesByName = new Map<string, ModelsDevModelItem[]>()
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 batchPricingProviderOptions = computed(() => {
const existingModelNames = new Set(batchManageModels.value.map(model => model.name.trim().toLowerCase())) const existingModelNames = new Set(batchManageModels.value.map(model => model.name.trim().toLowerCase()))
const providers = new Map<string, { const providers = new Map<string, {
@@ -1415,7 +1712,7 @@ const rememberedBatchPricingProviderIds = computed(() => {
if (batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID) return undefined if (batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID) return undefined
const providerIds = new Map<string, string>() const providerIds = new Map<string, string>()
for (const model of batchManageModels.value) { 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) if (source) providerIds.set(model.id, source.provider_id)
} }
return providerIds 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 batchPricingStateByModelId = computed(() => {
const states = new Map<string, 'syncable' | 'unchanged' | 'unsupported' | 'unavailable'>() const states = new Map<string, 'syncable' | 'source-pending' | 'unchanged' | 'unsupported' | 'unavailable'>()
for (const entry of batchPriceSyncPlan.value.syncable) states.set(entry.model.id, 'syncable') 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 entry of batchPriceSyncPlan.value.unsupported) states.set(entry.model.id, 'unsupported')
for (const model of batchPriceSyncPlan.value.unavailable) states.set(model.id, 'unavailable') for (const model of batchPriceSyncPlan.value.unavailable) states.set(model.id, 'unavailable')
return states return states
@@ -1450,11 +1762,25 @@ const selectedBatchPriceSyncPlan = computed(() => (
) )
)) ))
const selectedBatchPriceSyncEntries = computed(() => [
...selectedBatchPriceSyncPlan.value.syncable,
...selectedBatchPriceSyncPlan.value.unchanged.filter(doesBatchEntryNeedSourcePersistence),
])
const batchManageSelectionSummary = computed(() => { const batchManageSelectionSummary = computed(() => {
const selectedCount = selectedBatchManageModelIds.value.size const selectedCount = selectedBatchManageModelIds.value.size
if (selectedCount === 0) return '选择模型后执行批量操作' if (selectedCount === 0) return t('models.management.batch.selectionEmpty')
const plan = selectedBatchPriceSyncPlan.value 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) { function getBatchPricingState(model: GlobalModelResponse) {
@@ -1463,10 +1789,11 @@ function getBatchPricingState(model: GlobalModelResponse) {
function getBatchPricingStateLabel(model: GlobalModelResponse): string { function getBatchPricingStateLabel(model: GlobalModelResponse): string {
const state = getBatchPricingState(model) const state = getBatchPricingState(model)
if (state === 'syncable') return '价格可更新' if (state === 'syncable') return t('models.management.batch.state.syncable')
if (state === 'unchanged') return '价格一致' if (state === 'source-pending') return t('models.management.batch.state.sourcePending')
if (state === 'unsupported') return '计价不兼容' if (state === 'unchanged') return t('models.management.batch.state.unchanged')
return '无在线价格' if (state === 'unsupported') return t('models.management.batch.state.unsupported')
return t('models.management.batch.state.unavailable')
} }
function getBatchPricingStateDescription(model: GlobalModelResponse): string { function getBatchPricingStateDescription(model: GlobalModelResponse): string {
@@ -1480,14 +1807,17 @@ function getBatchPricingStateDescription(model: GlobalModelResponse): string {
function getBatchPricingSourceLabel(model: GlobalModelResponse): string { function getBatchPricingSourceLabel(model: GlobalModelResponse): string {
if (batchPricingProviderId.value !== REMEMBERED_PRICING_PROVIDER_ID) { 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 { function getBatchPricingStateClass(model: GlobalModelResponse): string {
const state = getBatchPricingState(model) const state = getBatchPricingState(model)
if (state === 'syncable') return 'text-amber-700 dark:text-amber-300' 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 === 'unchanged') return 'text-emerald-700 dark:text-emerald-300'
if (state === 'unsupported') return 'text-rose-700 dark:text-rose-300' if (state === 'unsupported') return 'text-rose-700 dark:text-rose-300'
return 'text-muted-foreground' return 'text-muted-foreground'
@@ -1496,6 +1826,7 @@ function getBatchPricingStateClass(model: GlobalModelResponse): string {
function getBatchPricingStateDotClass(model: GlobalModelResponse): string { function getBatchPricingStateDotClass(model: GlobalModelResponse): string {
const state = getBatchPricingState(model) const state = getBatchPricingState(model)
if (state === 'syncable') return 'bg-amber-500' if (state === 'syncable') return 'bg-amber-500'
if (state === 'source-pending') return 'bg-sky-500'
if (state === 'unchanged') return 'bg-emerald-500' if (state === 'unchanged') return 'bg-emerald-500'
if (state === 'unsupported') return 'bg-rose-500' if (state === 'unsupported') return 'bg-rose-500'
return 'bg-muted-foreground/45' return 'bg-muted-foreground/45'
@@ -1503,11 +1834,13 @@ function getBatchPricingStateDotClass(model: GlobalModelResponse): string {
function formatBatchUnsupportedPricingFields(model: ModelsDevModelItem): string { function formatBatchUnsupportedPricingFields(model: ModelsDevModelItem): string {
const labels = { const labels = {
reasoning: '推理 Token', reasoning: t('models.pricingField.reasoning'),
input_audio: '输入音频 Token', input_audio: t('models.pricingField.inputAudio'),
output_audio: '输出音频 Token', 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 filter: (m: GlobalModelResponse) => boolean
emphasis?: boolean emphasis?: boolean
}[] = [ }[] = [
{ label: '价格可更新', description: '当前价格与所选供应商在线价格不同', filter: m => getBatchPricingState(m) === 'syncable', emphasis: true }, { label: t('models.management.batch.state.syncable'), description: t('models.management.batch.shortcut.syncable'), filter: m => getBatchPricingState(m) === 'syncable', emphasis: true },
{ label: '价格一致', description: '当前价格与所选供应商在线价格一致', filter: m => getBatchPricingState(m) === 'unchanged' }, { label: t('models.management.batch.state.sourcePending'), description: t('models.management.batch.shortcut.sourcePending'), filter: m => getBatchPricingState(m) === 'source-pending', emphasis: true },
{ label: '计价不兼容', description: '在线来源包含当前计费引擎无法独立结算的价格维度', filter: m => getBatchPricingState(m) === 'unsupported' }, { label: t('models.management.batch.state.unchanged'), description: t('models.management.batch.shortcut.unchanged'), filter: m => getBatchPricingState(m) === 'unchanged' },
{ label: '无在线价格', description: '所选供应商没有该模型的在线价格', filter: m => getBatchPricingState(m) === 'unavailable' }, { 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.provider_count || 0) === 0 },
{ label: '无活跃提供商', description: '有提供商但没有活跃提供商的模型', filter: m => (m.active_provider_count || 0) === 0 && (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 }, { label: '禁用', description: '被禁用的模型', filter: m => !m.is_active },
@@ -1578,21 +1912,27 @@ function toggleAllBatchManageModels() {
function openBatchManageDialog() { function openBatchManageDialog() {
batchManageSearchQuery.value = '' batchManageSearchQuery.value = ''
batchPricingProviderId.value = REMEMBERED_PRICING_PROVIDER_ID batchPricingProviderId.value = REMEMBERED_PRICING_PROVIDER_ID
selectedBatchManageModelIds.value = new Set()
batchManageDialogOpen.value = true batchManageDialogOpen.value = true
void Promise.all([loadBatchManageModels(), loadBatchManageOnlineModels()]) void Promise.all([loadBatchManageModels(), loadBatchManageOnlineModels()])
} }
async function loadBatchManageOnlineModels() { async function loadBatchManageOnlineModels() {
batchManageOnlineLoading.value = true if (batchManageOnlineModels.value.length > 0) return
try { if (batchManageOnlineModelsRequest) return batchManageOnlineModelsRequest
batchManageOnlineModels.value = await getModelsDevList(false)
} catch (err: unknown) { batchManageOnlineModelsRequest = (async () => {
log.error('加载在线模型价格失败:', err) batchManageOnlineLoading.value = true
showError(parseApiError(err, '加载在线模型价格失败'), '加载失败') try {
} finally { batchManageOnlineModels.value = await getModelsDevList(false)
batchManageOnlineLoading.value = false } catch (err: unknown) {
} log.error('加载在线模型价格失败:', err)
showError(parseApiError(err, '加载在线模型价格失败'), '加载失败')
} finally {
batchManageOnlineLoading.value = false
batchManageOnlineModelsRequest = null
}
})()
return batchManageOnlineModelsRequest
} }
async function runBatchTasksWithConcurrency( async function runBatchTasksWithConcurrency(
@@ -1614,15 +1954,29 @@ async function runBatchTasksWithConcurrency(
async function confirmBatchSyncPrices() { async function confirmBatchSyncPrices() {
const plan = selectedBatchPriceSyncPlan.value 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 const providerName = batchPricingProviderId.value === REMEMBERED_PRICING_PROVIDER_ID
? '各模型上次选择的提供商' ? t('models.management.batch.rememberedProviderLabel')
: selectedBatchPricingProvider.value?.providerName || '所选供应商' : selectedBatchPricingProvider.value?.providerName
const skippedCount = plan.unchanged.length + plan.unsupported.length + plan.unavailable.length || 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({ const confirmed = await confirm({
title: '批量同步模型价格', title: t('models.management.batch.confirmTitle'),
message: `将根据 ${providerName} 的在线定价更新 ${plan.syncable.length} 个模型。${skippedCount > 0 ? `另有 ${skippedCount} 个模型因价格一致、计价不兼容或无在线价格而跳过。` : ''}\n\n仅更新模型价格,不修改名称、能力或其他配置。`, message: `${t('models.management.batch.confirmMain', {
confirmText: '同步价格', provider: providerName,
count: entries.length,
})}${sourceOnlyMessage}${skippedMessage}\n\n${t('models.management.batch.confirmUnchanged')}`,
confirmText: t('models.management.batch.confirmButton'),
variant: 'info', variant: 'info',
}) })
if (!confirmed) return if (!confirmed) return
@@ -1633,18 +1987,27 @@ async function confirmBatchSyncPrices() {
const failureMessages: string[] = [] const failureMessages: string[] = []
let successCount = 0 let successCount = 0
try { try {
const tasks = plan.syncable.map(entry => async () => { const tasks = entries.map(entry => async () => {
try { try {
const onlinePricing = entry.onlineModel.tieredPricing const onlinePricing = entry.onlineModel.tieredPricing
if (!onlinePricing) { if (!onlinePricing) {
throw new Error('在线目录未提供价格配置') throw new Error(t('models.management.batch.catalogMissingPrice'))
} }
const pricing = cloneTieredPricingConfig(onlinePricing) const pricing = cloneTieredPricingConfig(onlinePricing)
await updateGlobalModel(entry.model.id, { default_tiered_pricing: pricing }) const source = {
entry.model.default_tiered_pricing = pricing
setModelsDevPricingSource(entry.model.id, {
provider_id: entry.onlineModel.providerId, provider_id: entry.onlineModel.providerId,
provider_name: entry.onlineModel.providerName, 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 successCount += 1
} catch (err: unknown) { } catch (err: unknown) {
@@ -1654,9 +2017,17 @@ async function confirmBatchSyncPrices() {
}) })
await runBatchTasksWithConcurrency(tasks) await runBatchTasksWithConcurrency(tasks)
if (successCount > 0) success(`成功同步 ${successCount} 个模型价格`) if (successCount > 0) {
success(t('models.management.batch.success', { count: successCount }))
}
if (failureMessages.length > 0) { 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()]) await Promise.all([loadGlobalModels(), loadBatchManageModels()])
selectedBatchManageModelIds.value = failedIds 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]) => { watch([batchPricingProviderOptions, batchManageDialogOpen], ([options, dialogOpen]) => {
if (!dialogOpen) return if (!dialogOpen) return
if ( if (
@@ -1828,20 +2193,11 @@ async function editModel(model: GlobalModelResponse) {
} }
function handleModelPricingSynced(model: GlobalModelResponse) { function handleModelPricingSynced(model: GlobalModelResponse) {
const updatePricing = (models: GlobalModelResponse[]) => { applyGlobalModelUpdate({
const current = models.find(entry => entry.id === model.id) ...model,
if (current) { default_tiered_pricing: cloneTieredPricingConfig(model.default_tiered_pricing),
current.default_tiered_pricing = cloneTieredPricingConfig(model.default_tiered_pricing) config: model.config ? { ...model.config } : model.config,
} })
}
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)
}
} }
async function deleteModel(model: GlobalModelResponse) { async function deleteModel(model: GlobalModelResponse) {
@@ -1857,6 +2213,7 @@ async function deleteModel(model: GlobalModelResponse) {
if (selectedModel.value?.id === model.id) { if (selectedModel.value?.id === model.id) {
selectedModel.value = null selectedModel.value = null
} }
setBatchManageModelSelection(model.id, false)
await loadGlobalModels() await loadGlobalModels()
} catch (err: unknown) { } catch (err: unknown) {
showError(parseApiError(err, '删除失败'), '删除失败') showError(parseApiError(err, '删除失败'), '删除失败')
@@ -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<GlobalModelResponse>,
) => {
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<HTMLInputElement>(
'[data-testid="model-select-desktop-model-1"]',
)
const mobileCheckbox = document.body.querySelector<HTMLInputElement>(
'[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',
)
})
})