mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
feat(frontend): persist model pricing sources
This commit is contained in:
@@ -408,10 +408,14 @@
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
size="sm"
|
||||
class="h-8 min-w-0 max-w-56 shrink-0 gap-1.5 px-2.5"
|
||||
: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="同步最新在线价格"
|
||||
data-testid="sync-online-pricing"
|
||||
@click="syncOnlinePricing"
|
||||
@@ -420,6 +424,11 @@
|
||||
class="h-4 w-4"
|
||||
:class="syncingOnlinePricing ? 'animate-spin' : ''"
|
||||
/>
|
||||
<span class="truncate text-xs">
|
||||
{{ currentOnlinePricingSource
|
||||
? t('models.pricingSource.buttonCurrent', { provider: currentOnlinePricingSource.provider_name })
|
||||
: t('models.pricingSource.choose') }}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -771,6 +780,7 @@ import {
|
||||
} from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
@@ -797,7 +807,12 @@ import {
|
||||
tieredPricingConfigsEqual,
|
||||
} from './global-model-form-helpers'
|
||||
import { tieredPricingHasImageOutputPricing } from '../utils/tiered-pricing'
|
||||
import { useModelsDevPricingSources } from '../composables/useModelsDevPricingSources'
|
||||
import {
|
||||
getModelsDevPricingSourceFromConfig,
|
||||
modelsDevPricingSourcesEqual,
|
||||
useModelsDevPricingSources,
|
||||
withModelsDevPricingSource,
|
||||
} from '../composables/useModelsDevPricingSources'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -812,6 +827,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { t } = useI18n()
|
||||
const { getSource, setSource } = useModelsDevPricingSources()
|
||||
const submitting = ref(false)
|
||||
const syncingOnlinePricing = ref(false)
|
||||
@@ -842,6 +858,9 @@ const selectedOnlinePricingCandidate = computed(() => (
|
||||
candidate.providerId === selectedOnlinePricingProviderId.value
|
||||
)) ?? null
|
||||
))
|
||||
const currentOnlinePricingSource = computed(() => (
|
||||
props.model ? getSource(props.model.id, props.model.config) : null
|
||||
))
|
||||
const firstSyncableOnlinePricingProviderId = computed(() => (
|
||||
onlinePricingCandidates.value.find(isOnlinePricingCandidateSyncable)?.providerId ?? ''
|
||||
))
|
||||
@@ -1407,7 +1426,7 @@ function resolveOnlinePricingModel(
|
||||
): ModelsDevModelItem | null {
|
||||
const modelId = normalizeModelId(model.name)
|
||||
const transientSource = editingOnlinePricingSource.value
|
||||
const storedSource = getSource(model.id)
|
||||
const storedSource = getSource(model.id, model.config)
|
||||
const preferredProviderId = transientSource?.model_id && normalizeModelId(transientSource.model_id) === modelId
|
||||
? transientSource.provider_id
|
||||
: storedSource?.provider_id
|
||||
@@ -1530,28 +1549,44 @@ async function applyOnlinePricingModel(onlineModel: ModelsDevModelItem) {
|
||||
props.model.default_tiered_pricing,
|
||||
pricing,
|
||||
)
|
||||
const pricingSource = {
|
||||
provider_id: onlineModel.providerId,
|
||||
provider_name: onlineModel.providerName,
|
||||
}
|
||||
const sourceChanged = !modelsDevPricingSourcesEqual(
|
||||
getModelsDevPricingSourceFromConfig(props.model.config),
|
||||
pricingSource,
|
||||
)
|
||||
const nextConfig = withModelsDevPricingSource(props.model.config, pricingSource)
|
||||
let syncedModel: GlobalModelResponse
|
||||
if (pricingChanged) {
|
||||
syncedModel = await updateGlobalModel(props.model.id, {
|
||||
if (pricingChanged || sourceChanged) {
|
||||
const updatedModel = await updateGlobalModel(props.model.id, {
|
||||
default_tiered_pricing: pricing,
|
||||
config: nextConfig,
|
||||
})
|
||||
syncedModel = {
|
||||
...updatedModel,
|
||||
default_tiered_pricing: pricing,
|
||||
config: nextConfig,
|
||||
}
|
||||
} else {
|
||||
syncedModel = {
|
||||
...props.model,
|
||||
default_tiered_pricing: pricing,
|
||||
config: nextConfig,
|
||||
}
|
||||
}
|
||||
tieredPricing.value = cloneTieredPricingConfig(pricing)
|
||||
form.value.config = { ...nextConfig }
|
||||
billingMode.value = 'token'
|
||||
setSource(props.model.id, {
|
||||
provider_id: onlineModel.providerId,
|
||||
provider_name: onlineModel.providerName,
|
||||
})
|
||||
setSource(props.model.id, pricingSource)
|
||||
emit('pricingSynced', syncedModel)
|
||||
success(
|
||||
pricingChanged
|
||||
? `已同步 ${onlineModel.providerName} 的最新价格`
|
||||
: `当前价格已是 ${onlineModel.providerName} 的最新价格`,
|
||||
: sourceChanged
|
||||
? t('models.pricingSource.savedNoPriceChange', { provider: onlineModel.providerName })
|
||||
: `当前价格已是 ${onlineModel.providerName} 的最新价格`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1725,6 +1760,12 @@ async function handleSubmit() {
|
||||
success('模型更新成功')
|
||||
} else {
|
||||
const createData = buildGlobalModelCreatePayload(form.value, finalTieredPricing)
|
||||
if (selectedModel.value) {
|
||||
createData.config = withModelsDevPricingSource(createData.config, {
|
||||
provider_id: selectedModel.value.providerId,
|
||||
provider_name: selectedModel.value.providerName,
|
||||
})
|
||||
}
|
||||
const createdModel = await createGlobalModel(createData)
|
||||
existingModelsCache.value.unshift(createdModel)
|
||||
if (selectedModel.value) {
|
||||
|
||||
@@ -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',
|
||||
input_modalities: ['text'],
|
||||
output_modalities: ['text'],
|
||||
models_dev_pricing_source: {
|
||||
provider_id: 'openai',
|
||||
provider_name: 'OpenAI',
|
||||
},
|
||||
},
|
||||
default_tiered_pricing: {
|
||||
tiers: [
|
||||
@@ -477,9 +481,17 @@ describe('GlobalModelFormDialog preset replacement', () => {
|
||||
|
||||
it('refreshes and applies the latest online price from the edit dialog', async () => {
|
||||
const existingStaleModel = buildExistingStaleModel()
|
||||
const nextConfig = {
|
||||
...existingStaleModel.config,
|
||||
models_dev_pricing_source: {
|
||||
provider_id: stalePreset.providerId,
|
||||
provider_name: stalePreset.providerName,
|
||||
},
|
||||
}
|
||||
const syncedModel = {
|
||||
...existingStaleModel,
|
||||
default_tiered_pricing: stalePreset.tieredPricing!,
|
||||
config: nextConfig,
|
||||
}
|
||||
globalModelMocks.updateGlobalModel.mockResolvedValue(syncedModel)
|
||||
globalModelMocks.listGlobalModels.mockResolvedValue({
|
||||
@@ -495,7 +507,7 @@ describe('GlobalModelFormDialog preset replacement', () => {
|
||||
'[data-testid="sync-online-pricing"]',
|
||||
)
|
||||
if (!syncButton) throw new Error('Missing online pricing sync button')
|
||||
expect(syncButton.title).toBe('同步最新在线价格')
|
||||
expect(syncButton.title).toBe('选择并同步在线价格来源')
|
||||
expect(syncButton.getAttribute('aria-label')).toBe('同步最新在线价格')
|
||||
|
||||
syncButton.click()
|
||||
@@ -505,7 +517,10 @@ describe('GlobalModelFormDialog preset replacement', () => {
|
||||
expect(modelsDevMocks.refreshModelsDevList).toHaveBeenCalledWith(false)
|
||||
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
|
||||
existingStaleModel.id,
|
||||
{ default_tiered_pricing: stalePreset.tieredPricing },
|
||||
{
|
||||
default_tiered_pricing: stalePreset.tieredPricing,
|
||||
config: nextConfig,
|
||||
},
|
||||
)
|
||||
expect(pricingSynced).toHaveBeenCalledWith(syncedModel)
|
||||
expect(document.body.querySelector<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 () => {
|
||||
const existingStaleModel = buildExistingStaleModel()
|
||||
const nextConfig = {
|
||||
...existingStaleModel.config,
|
||||
models_dev_pricing_source: {
|
||||
provider_id: alternateStalePreset.providerId,
|
||||
provider_name: alternateStalePreset.providerName,
|
||||
},
|
||||
}
|
||||
const syncedModel = {
|
||||
...existingStaleModel,
|
||||
default_tiered_pricing: alternateStalePreset.tieredPricing!,
|
||||
config: nextConfig,
|
||||
}
|
||||
modelsDevMocks.refreshModelsDevList.mockResolvedValue([
|
||||
unavailableStalePreset,
|
||||
@@ -578,7 +644,10 @@ describe('GlobalModelFormDialog preset replacement', () => {
|
||||
|
||||
expect(globalModelMocks.updateGlobalModel).toHaveBeenCalledWith(
|
||||
existingStaleModel.id,
|
||||
{ default_tiered_pricing: alternateStalePreset.tieredPricing },
|
||||
{
|
||||
default_tiered_pricing: alternateStalePreset.tieredPricing,
|
||||
config: nextConfig,
|
||||
},
|
||||
)
|
||||
expect(pricingSynced).toHaveBeenCalledWith(syncedModel)
|
||||
expect(document.body.textContent).not.toContain('选择在线价格来源')
|
||||
|
||||
+60
-2
@@ -1,6 +1,11 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useModelsDevPricingSources } from '../useModelsDevPricingSources'
|
||||
import {
|
||||
getModelsDevPricingSourceFromConfig,
|
||||
modelsDevPricingSourcesEqual,
|
||||
useModelsDevPricingSources,
|
||||
withModelsDevPricingSource,
|
||||
} from '../useModelsDevPricingSources'
|
||||
|
||||
const STORAGE_KEY = 'aether:models-dev-pricing-sources:v1'
|
||||
const LEGACY_STORAGE_KEY = 'aether:models-dev-pricing-preferences:v1'
|
||||
@@ -33,6 +38,24 @@ describe('useModelsDevPricingSources', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the database-backed model config over the local migration fallback', () => {
|
||||
const { getSource, setSource } = useModelsDevPricingSources()
|
||||
setSource('model-1', {
|
||||
provider_id: 'openai',
|
||||
provider_name: 'OpenAI',
|
||||
})
|
||||
|
||||
expect(getSource('model-1', {
|
||||
models_dev_pricing_source: {
|
||||
provider_id: 'anthropic',
|
||||
provider_name: 'Anthropic',
|
||||
},
|
||||
})).toEqual({
|
||||
provider_id: 'anthropic',
|
||||
provider_name: 'Anthropic',
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates the previous provider record without retaining its automatic preference key', () => {
|
||||
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({
|
||||
version: 1,
|
||||
@@ -65,4 +88,39 @@ describe('useModelsDevPricingSources', () => {
|
||||
|
||||
expect(getSource('model-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export const MODELS_DEV_PRICING_SOURCE_CONFIG_KEY = 'models_dev_pricing_source'
|
||||
|
||||
interface StoredModelsDevPricingSources {
|
||||
version: 1
|
||||
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 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 {
|
||||
try {
|
||||
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> = {}
|
||||
for (const [modelId, value] of Object.entries(document.models)) {
|
||||
if (!value || typeof value !== 'object') continue
|
||||
const source = value as Partial<ModelsDevPricingSource>
|
||||
if (
|
||||
typeof source.provider_id === 'string'
|
||||
&& source.provider_id.length > 0
|
||||
&& typeof source.provider_name === 'string'
|
||||
&& source.provider_name.length > 0
|
||||
) {
|
||||
validSources[modelId] = {
|
||||
provider_id: source.provider_id,
|
||||
provider_name: source.provider_name,
|
||||
}
|
||||
}
|
||||
const source = normalizePricingSource(value)
|
||||
if (source) validSources[modelId] = source
|
||||
}
|
||||
return validSources
|
||||
} catch {
|
||||
@@ -75,14 +110,23 @@ function readStoredSources(): Record<string, ModelsDevPricingSource> {
|
||||
export function useModelsDevPricingSources() {
|
||||
sources.value = readStoredSources()
|
||||
|
||||
function getSource(modelId: string): ModelsDevPricingSource | null {
|
||||
function getLocalSource(modelId: string): ModelsDevPricingSource | null {
|
||||
return sources.value[modelId] ?? null
|
||||
}
|
||||
|
||||
function getSource(
|
||||
modelId: string,
|
||||
config?: Record<string, unknown> | null,
|
||||
): ModelsDevPricingSource | null {
|
||||
return getModelsDevPricingSourceFromConfig(config) ?? getLocalSource(modelId)
|
||||
}
|
||||
|
||||
function setSource(modelId: string, source: ModelsDevPricingSource) {
|
||||
const normalizedSource = normalizePricingSource(source)
|
||||
if (!normalizedSource) return
|
||||
const nextSources = {
|
||||
...sources.value,
|
||||
[modelId]: source,
|
||||
[modelId]: normalizedSource,
|
||||
}
|
||||
sources.value = nextSources
|
||||
writeStoredSources(nextSources)
|
||||
@@ -90,6 +134,7 @@ export function useModelsDevPricingSources() {
|
||||
|
||||
return {
|
||||
getSource,
|
||||
getLocalSource,
|
||||
setSource,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user