mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
feat(frontend): persist model pricing sources
This commit is contained in:
@@ -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>
|
||||||
+72
-3
@@ -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('选择在线价格来源')
|
||||||
|
|||||||
+59
-1
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user