mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 18:37:46 +08:00
Merge remote-tracking branch 'origin/main' into codex/usage-pending-reasoning-reset-expiry-20260712
# Conflicts: # frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
This commit is contained in:
@@ -160,122 +160,169 @@
|
||||
</div>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold text-sm border-b pb-2">
|
||||
价格配置
|
||||
<section class="space-y-3 rounded-lg border bg-card p-4">
|
||||
<h4 class="font-medium text-sm">
|
||||
选择计费模式
|
||||
</h4>
|
||||
<TieredPricingEditor
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
:show-image-pricing="isImageGenerationEnabled"
|
||||
/>
|
||||
<Tabs
|
||||
v-model="billingMode"
|
||||
@update:model-value="handleBillingModeChange"
|
||||
>
|
||||
<TabsList class="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="token">
|
||||
Token
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="request">
|
||||
按次
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="image">
|
||||
图片
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="video">
|
||||
视频
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- 按次计费 -->
|
||||
<div class="flex items-center gap-3 pt-2 border-t">
|
||||
<Label class="text-xs whitespace-nowrap">按次计费 ($/次)</Label>
|
||||
<Input
|
||||
:model-value="form.price_per_request ?? ''"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="w-32"
|
||||
placeholder="留空使用默认值"
|
||||
@update:model-value="(v) => form.price_per_request = parseNumberInput(v, { allowFloat: true })"
|
||||
<TieredPricingEditor
|
||||
v-show="billingMode === 'token' || billingMode === 'image'"
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
class="mt-3"
|
||||
:auto-fill-missing-cache-prices="autoFillMissingCachePrices"
|
||||
:show-token-pricing="billingMode === 'token'"
|
||||
:show-image-pricing="isImageGenerationEnabled"
|
||||
:show-image-editor="billingMode === 'image'"
|
||||
:show-processing-tier-multiplier-controls="true"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">每次请求固定费用,留空使用全局模型默认值</span>
|
||||
</div>
|
||||
|
||||
<!-- 视频计费(可选覆盖) -->
|
||||
<div class="pt-3 border-t space-y-2">
|
||||
<div class="text-sm font-medium">
|
||||
视频计费(可选覆盖)
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('common'); configTouched = true }"
|
||||
>
|
||||
通用
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('sora'); configTouched = true }"
|
||||
>
|
||||
Sora
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('veo'); configTouched = true }"
|
||||
>
|
||||
Veo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { addVideoResolutionPriceRow(); configTouched = true }"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-0.5" />
|
||||
自定义
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="videoResolutionPrices.length > 0"
|
||||
class="rounded-lg border border-border overflow-hidden"
|
||||
<TabsContent
|
||||
value="request"
|
||||
class="pt-2"
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||
<span>分辨率</span>
|
||||
<span>单价($/秒)</span>
|
||||
<span />
|
||||
<div class="rounded-lg border bg-muted/20 p-4 space-y-2">
|
||||
<Label class="text-xs">每次请求价格(美元)</Label>
|
||||
<Input
|
||||
:model-value="form.price_per_request ?? ''"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="max-w-48"
|
||||
placeholder="留空使用全局模型默认值"
|
||||
@update:model-value="updatePricePerRequest"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
按每次 API 请求收取固定费用;未修改时继续继承全局模型。
|
||||
</p>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<div
|
||||
v-for="(row, idx) in videoResolutionPrices"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
|
||||
>
|
||||
<Input
|
||||
v-model="row.resolution"
|
||||
class="h-7 text-sm"
|
||||
placeholder="如 720p"
|
||||
@update:model-value="() => { configTouched = true }"
|
||||
/>
|
||||
<Input
|
||||
:model-value="row.price_per_second ?? ''"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0"
|
||||
class="h-7 text-sm"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => { row.price_per_second = parseNumberInput(v, { allowFloat: true }); configTouched = true }"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="video"
|
||||
class="pt-2"
|
||||
>
|
||||
<div class="space-y-3 rounded-lg border bg-muted/20 p-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
视频计费(分辨率 × 时长)
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
根据输出分辨率配置每秒视频价格;未修改时继续继承全局模型。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除"
|
||||
@click="() => { removeVideoResolutionPriceRow(idx); configTouched = true }"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('common'); configTouched = true }"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
通用
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('sora'); configTouched = true }"
|
||||
>
|
||||
Sora
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('veo'); configTouched = true }"
|
||||
>
|
||||
Veo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { addVideoResolutionPriceRow(); configTouched = true }"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-0.5" />
|
||||
自定义
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="videoResolutionPrices.length > 0"
|
||||
class="rounded-lg border border-border overflow-hidden"
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||
<span>分辨率</span>
|
||||
<span>单价($/秒)</span>
|
||||
<span />
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<div
|
||||
v-for="(row, idx) in videoResolutionPrices"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
|
||||
>
|
||||
<Input
|
||||
v-model="row.resolution"
|
||||
class="h-7 text-sm"
|
||||
placeholder="如 720p"
|
||||
@update:model-value="() => { configTouched = true }"
|
||||
/>
|
||||
<Input
|
||||
:model-value="row.price_per_second ?? ''"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0"
|
||||
class="h-7 text-sm"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => { row.price_per_second = parseNumberInput(v, { allowFloat: true }); configTouched = true }"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除"
|
||||
@click="() => { removeVideoResolutionPriceRow(idx); configTouched = true }"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
选择一个价格预设或添加自定义分辨率
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -315,17 +362,32 @@ import {
|
||||
SelectItem,
|
||||
Badge,
|
||||
Checkbox,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
||||
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
|
||||
import { createGlobalModel, listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
import {
|
||||
createGlobalModel,
|
||||
getGlobalModel,
|
||||
listGlobalModels,
|
||||
type GlobalModelResponse,
|
||||
} from '@/api/global-models'
|
||||
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
|
||||
import { tieredPricingHasImageOutputPricing } from '@/features/models/utils/tiered-pricing'
|
||||
import type { Model, TieredPricingConfig } from '@/api/endpoints'
|
||||
import type {
|
||||
Model,
|
||||
ProviderTieredPricingConfig,
|
||||
TieredPricingConfig,
|
||||
} from '@/api/endpoints'
|
||||
import {
|
||||
buildProviderTieredPricingOverride,
|
||||
buildProviderModelCreatePayload,
|
||||
buildProviderModelUpdatePayload,
|
||||
mergeProviderTieredPricingForEditing,
|
||||
modelSupportsEmbedding,
|
||||
} from './provider-model-form-helpers'
|
||||
|
||||
@@ -385,6 +447,7 @@ const submitting = ref(false)
|
||||
const loadingGlobalModels = ref(false)
|
||||
const availableGlobalModels = ref<GlobalModelResponse[]>([])
|
||||
const manualGlobalModelMode = ref(false)
|
||||
const billingMode = ref('token')
|
||||
|
||||
// 阶梯计费配置
|
||||
const tieredPricing = ref<TieredPricingConfig | null>(null)
|
||||
@@ -392,6 +455,10 @@ const tieredPricing = ref<TieredPricingConfig | null>(null)
|
||||
const tieredPricingModified = ref(false)
|
||||
// 保存原始配置用于比较
|
||||
const originalTieredPricing = ref<string>('')
|
||||
const originalEditorTieredPricing = ref<TieredPricingConfig | null>(null)
|
||||
const originalProviderTieredPricing = ref<ProviderTieredPricingConfig | null>(null)
|
||||
const pricePerRequestModified = ref(false)
|
||||
const originalPricePerRequest = ref<number | undefined>(undefined)
|
||||
|
||||
type VideoResolutionPriceRow = { resolution: string; price_per_second: number | undefined }
|
||||
|
||||
@@ -439,6 +506,7 @@ const form = ref({
|
||||
is_active: true
|
||||
})
|
||||
const imageGenerationExplicitOverride = ref<boolean | null>(null)
|
||||
const autoFillMissingCachePrices = computed(() => !isEditing.value && manualGlobalModelMode.value)
|
||||
|
||||
const canSubmitCreate = computed(() => {
|
||||
if (isEditing.value) return true
|
||||
@@ -455,7 +523,6 @@ watch(() => props.open, async (newOpen) => {
|
||||
// 编辑模式:填充表单
|
||||
// 使用有效配置(合并全局模型的默认值)供用户查看和编辑
|
||||
const effectiveConfig = props.editingModel.effective_config || props.editingModel.config || {}
|
||||
const supportsImageGeneration = modelSupportsImageGeneration(props.editingModel)
|
||||
form.value = {
|
||||
global_model_id: props.editingModel.global_model_id || '',
|
||||
provider_model_name: props.editingModel.provider_model_name || '',
|
||||
@@ -468,16 +535,45 @@ watch(() => props.open, async (newOpen) => {
|
||||
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
|
||||
supports_streaming: props.editingModel.supports_streaming ?? undefined,
|
||||
supports_extended_thinking: props.editingModel.supports_extended_thinking ?? undefined,
|
||||
supports_image_generation: supportsImageGeneration ? true : props.editingModel.supports_image_generation ?? undefined,
|
||||
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
|
||||
is_active: props.editingModel.is_active
|
||||
}
|
||||
// 从有效配置中加载视频费用
|
||||
loadVideoPricingFromConfig(effectiveConfig)
|
||||
// 加载阶梯计费配置:优先使用 Provider 自定义配置,否则使用有效配置(继承自全局模型)
|
||||
const pricing = props.editingModel.tiered_pricing || props.editingModel.effective_tiered_pricing
|
||||
// Provider 可以只覆盖 processing_tiers。此时后端 effective_tiered_pricing
|
||||
// 仍是原始 partial JSON,需要取 GlobalModel 默认目录合成完整编辑视图。
|
||||
const providerPricing = props.editingModel.tiered_pricing
|
||||
? JSON.parse(JSON.stringify(props.editingModel.tiered_pricing)) as ProviderTieredPricingConfig
|
||||
: null
|
||||
let globalDefaultPricing = providerPricing
|
||||
? null
|
||||
: props.editingModel.effective_tiered_pricing
|
||||
if (providerPricing && props.editingModel.global_model_id) {
|
||||
try {
|
||||
const globalModel = await getGlobalModel(props.editingModel.global_model_id)
|
||||
globalDefaultPricing = globalModel.default_tiered_pricing
|
||||
} catch (err: unknown) {
|
||||
if (!providerPricing.tiers?.length) {
|
||||
showError(parseApiError(err, '加载 GlobalModel 默认价格失败'), '错误')
|
||||
}
|
||||
}
|
||||
}
|
||||
const pricing = mergeProviderTieredPricingForEditing(globalDefaultPricing, providerPricing)
|
||||
|| (props.editingModel.effective_tiered_pricing?.tiers?.length
|
||||
? props.editingModel.effective_tiered_pricing
|
||||
: null)
|
||||
if (pricing) {
|
||||
tieredPricing.value = JSON.parse(JSON.stringify(pricing))
|
||||
}
|
||||
originalEditorTieredPricing.value = tieredPricing.value
|
||||
? JSON.parse(JSON.stringify(tieredPricing.value))
|
||||
: null
|
||||
originalProviderTieredPricing.value = providerPricing
|
||||
originalTieredPricing.value = JSON.stringify(tieredPricing.value)
|
||||
tieredPricingModified.value = false
|
||||
originalPricePerRequest.value = form.value.price_per_request
|
||||
pricePerRequestModified.value = false
|
||||
selectInitialBillingMode()
|
||||
} else {
|
||||
// 添加模式:加载可用全局模型
|
||||
await loadAvailableGlobalModels()
|
||||
@@ -497,21 +593,30 @@ watch(() => form.value.global_model_id, (newId) => {
|
||||
// 深拷贝阶梯计费配置用于预览
|
||||
const pricingCopy = JSON.parse(JSON.stringify(selectedModel.default_tiered_pricing))
|
||||
tieredPricing.value = pricingCopy
|
||||
originalEditorTieredPricing.value = JSON.parse(JSON.stringify(pricingCopy))
|
||||
originalProviderTieredPricing.value = null
|
||||
// 保存原始配置用于比较
|
||||
originalTieredPricing.value = JSON.stringify(pricingCopy)
|
||||
} else {
|
||||
tieredPricing.value = null
|
||||
originalTieredPricing.value = ''
|
||||
originalEditorTieredPricing.value = null
|
||||
originalProviderTieredPricing.value = null
|
||||
originalTieredPricing.value = JSON.stringify(null)
|
||||
}
|
||||
tieredPricingModified.value = false
|
||||
// 同时继承按次计费(仅供预览)
|
||||
form.value.price_per_request = selectedModel?.default_price_per_request ?? undefined
|
||||
originalPricePerRequest.value = form.value.price_per_request
|
||||
pricePerRequestModified.value = false
|
||||
loadVideoPricingFromConfig(selectedModel?.config || {})
|
||||
configTouched.value = false
|
||||
selectInitialBillingMode()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听阶梯配置变化,标记为已修改
|
||||
watch(tieredPricing, (newValue) => {
|
||||
if (!isEditing.value && originalTieredPricing.value) {
|
||||
if (originalTieredPricing.value) {
|
||||
const newJson = JSON.stringify(newValue)
|
||||
tieredPricingModified.value = newJson !== originalTieredPricing.value
|
||||
}
|
||||
@@ -539,8 +644,37 @@ function resetForm() {
|
||||
tieredPricing.value = null
|
||||
tieredPricingModified.value = false
|
||||
originalTieredPricing.value = ''
|
||||
originalEditorTieredPricing.value = null
|
||||
originalProviderTieredPricing.value = null
|
||||
pricePerRequestModified.value = false
|
||||
originalPricePerRequest.value = undefined
|
||||
availableGlobalModels.value = []
|
||||
manualGlobalModelMode.value = false
|
||||
billingMode.value = 'token'
|
||||
}
|
||||
|
||||
function updatePricePerRequest(value: string | number) {
|
||||
form.value.price_per_request = parseNumberInput(value, { allowFloat: true })
|
||||
pricePerRequestModified.value = form.value.price_per_request !== originalPricePerRequest.value
|
||||
}
|
||||
|
||||
function handleBillingModeChange(mode: string) {
|
||||
billingMode.value = mode
|
||||
if (mode === 'image' && !isImageGenerationEnabled.value) {
|
||||
setImageGenerationEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
function selectInitialBillingMode() {
|
||||
if (videoResolutionPrices.value.length > 0) {
|
||||
billingMode.value = 'video'
|
||||
} else if (isImageGenerationEnabled.value) {
|
||||
billingMode.value = 'image'
|
||||
} else if (form.value.price_per_request !== undefined) {
|
||||
billingMode.value = 'request'
|
||||
} else {
|
||||
billingMode.value = 'token'
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalModelSelect(value: string) {
|
||||
@@ -556,8 +690,8 @@ function modelSupportsImageGeneration(model: {
|
||||
supports_image_generation?: boolean | null
|
||||
effective_supports_image_generation?: boolean | null
|
||||
default_tiered_pricing?: TieredPricingConfig | null
|
||||
tiered_pricing?: TieredPricingConfig | null
|
||||
effective_tiered_pricing?: TieredPricingConfig | null
|
||||
tiered_pricing?: ProviderTieredPricingConfig | null
|
||||
effective_tiered_pricing?: ProviderTieredPricingConfig | null
|
||||
config?: Record<string, unknown> | null
|
||||
} | null | undefined): boolean {
|
||||
if (!model) return false
|
||||
@@ -798,8 +932,14 @@ async function handleSubmit() {
|
||||
try {
|
||||
// 获取包含自动计算缓存价格的最终数据
|
||||
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
|
||||
const supportsImageGeneration = isImageGenerationEnabled.value
|
||||
|| tieredPricingHasImageOutputPricing(finalTieredPricing)
|
||||
const providerTieredPricingOverride = tieredPricingModified.value
|
||||
? buildProviderTieredPricingOverride(
|
||||
finalTieredPricing,
|
||||
originalEditorTieredPricing.value,
|
||||
originalProviderTieredPricing.value,
|
||||
)
|
||||
: null
|
||||
const supportsImageGeneration = form.value.supports_image_generation
|
||||
|
||||
// Apply billing (video) pricing into config.
|
||||
applyVideoPricingToConfig(form.value.config)
|
||||
@@ -809,11 +949,14 @@ async function handleSubmit() {
|
||||
|
||||
if (isEditing.value && props.editingModel) {
|
||||
// 编辑模式
|
||||
// 注意:使用 null 而不是 undefined 来显式清空字段(undefined 会被 JSON 序列化忽略)
|
||||
// 仅提交实际修改的 Provider 覆盖;未修改字段继续继承全局模型。
|
||||
await updateModel(props.providerId, props.editingModel.id, buildProviderModelUpdatePayload({
|
||||
finalTieredPricing,
|
||||
finalTieredPricing: providerTieredPricingOverride,
|
||||
tieredPricingModified: tieredPricingModified.value,
|
||||
pricePerRequest: form.value.price_per_request,
|
||||
pricePerRequestModified: pricePerRequestModified.value,
|
||||
cleanConfig,
|
||||
configTouched: configTouched.value,
|
||||
supportsVision: form.value.supports_vision,
|
||||
supportsFunctionCalling: form.value.supports_function_calling,
|
||||
supportsStreaming: form.value.supports_streaming,
|
||||
@@ -834,9 +977,10 @@ async function handleSubmit() {
|
||||
await createModel(props.providerId, buildProviderModelCreatePayload({
|
||||
globalModelId: selectedModel.id,
|
||||
providerModelName: form.value.provider_model_name.trim(),
|
||||
finalTieredPricing,
|
||||
finalTieredPricing: providerTieredPricingOverride,
|
||||
tieredPricingModified: manualGlobalModelMode.value ? false : tieredPricingModified.value,
|
||||
pricePerRequest: manualGlobalModelMode.value ? undefined : form.value.price_per_request,
|
||||
pricePerRequestModified: manualGlobalModelMode.value ? false : pricePerRequestModified.value,
|
||||
cleanConfig,
|
||||
configTouched: manualGlobalModelMode.value ? false : configTouched.value,
|
||||
supportsVision: form.value.supports_vision,
|
||||
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
|
||||
|
||||
import type { Model } from '@/api/endpoints'
|
||||
import ProviderModelFormDialog from '../ProviderModelFormDialog.vue'
|
||||
|
||||
const modelMocks = vi.hoisted(() => ({
|
||||
createModel: vi.fn(),
|
||||
updateModel: vi.fn(),
|
||||
getProviderModels: vi.fn(),
|
||||
}))
|
||||
|
||||
const globalModelMocks = vi.hoisted(() => ({
|
||||
createGlobalModel: vi.fn(),
|
||||
getGlobalModel: vi.fn(),
|
||||
listGlobalModels: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/endpoints/models', () => modelMocks)
|
||||
vi.mock('@/api/global-models', () => globalModelMocks)
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
const editingModel = {
|
||||
id: 'provider-model-1',
|
||||
provider_id: 'provider-1',
|
||||
global_model_id: 'global-model-1',
|
||||
provider_model_name: 'gpt-test',
|
||||
tiered_pricing: null,
|
||||
price_per_request: null,
|
||||
effective_price_per_request: 0.25,
|
||||
config: null,
|
||||
effective_config: {
|
||||
billing: {
|
||||
video: {
|
||||
price_per_second_by_resolution: { '720p': 0.1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
effective_tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 2.5 },
|
||||
fast: { price_multiplier: 2 },
|
||||
hyperlane: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 8, output_price_per_1m: 48 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
is_active: true,
|
||||
is_available: true,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
} as Model
|
||||
|
||||
function mountDialog(model: Model | null = editingModel) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const open = ref(false)
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(ProviderModelFormDialog, {
|
||||
open: open.value,
|
||||
providerId: 'provider-1',
|
||||
editingModel: model,
|
||||
})
|
||||
},
|
||||
}))
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function findButton(text: string): HTMLButtonElement {
|
||||
const button = [...document.body.querySelectorAll('button')]
|
||||
.find(candidate => candidate.textContent?.trim() === text)
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error(`Missing button: ${text}`)
|
||||
return button
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
modelMocks.createModel.mockReset()
|
||||
modelMocks.updateModel.mockReset()
|
||||
modelMocks.updateModel.mockResolvedValue(editingModel)
|
||||
modelMocks.getProviderModels.mockReset()
|
||||
globalModelMocks.createGlobalModel.mockReset()
|
||||
globalModelMocks.getGlobalModel.mockReset()
|
||||
globalModelMocks.listGlobalModels.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('ProviderModelFormDialog processing-tier pricing', () => {
|
||||
it('uses the same compact Fast grouping for inherited global-model pricing', async () => {
|
||||
mountDialog()
|
||||
await settle()
|
||||
|
||||
expect(document.body.textContent).toContain('选择计费模式')
|
||||
for (const tab of ['Token', '按次', '图片', '视频']) {
|
||||
expect(findButton(tab)).toBeDefined()
|
||||
}
|
||||
findButton('Token').click()
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.querySelector('[data-processing-tier="standard"]')).not.toBeNull()
|
||||
expect(document.body.querySelector('[data-processing-tier="hyperlane"]')).not.toBeNull()
|
||||
const fastGroup = document.body.querySelector('[data-processing-tier-group="fast"]')
|
||||
expect(fastGroup?.textContent).toContain('Fast')
|
||||
expect(fastGroup?.textContent).toContain('OpenAI')
|
||||
expect(fastGroup?.textContent).toContain('Chat / Responses')
|
||||
expect(fastGroup?.textContent).toContain('Claude')
|
||||
expect(fastGroup?.textContent).toContain('Messages')
|
||||
expect(document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
)?.value).toBe('2.5')
|
||||
expect(document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-fast"]',
|
||||
)?.value).toBe('2')
|
||||
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
const payload = modelMocks.updateModel.mock.calls[0][2]
|
||||
expect(payload).not.toHaveProperty('tiered_pricing')
|
||||
expect(payload).not.toHaveProperty('price_per_request')
|
||||
expect(payload).not.toHaveProperty('config')
|
||||
})
|
||||
|
||||
it('creates a Provider price override only after the inherited price is edited', async () => {
|
||||
mountDialog()
|
||||
await settle()
|
||||
findButton('Token').click()
|
||||
await nextTick()
|
||||
const multiplier = document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
)
|
||||
if (!multiplier) throw new Error('Missing OpenAI Fast multiplier')
|
||||
|
||||
multiplier.value = '3'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
const payload = modelMocks.updateModel.mock.calls[0][2]
|
||||
expect(payload.tiered_pricing.processing_tiers.priority).toEqual({
|
||||
price_multiplier: 3,
|
||||
})
|
||||
expect(payload.tiered_pricing).not.toHaveProperty('tiers')
|
||||
expect(payload.tiered_pricing.processing_tiers).not.toHaveProperty('fast')
|
||||
expect(payload.tiered_pricing.processing_tiers).not.toHaveProperty('hyperlane')
|
||||
expect(payload).not.toHaveProperty('price_per_request')
|
||||
expect(payload).not.toHaveProperty('config')
|
||||
})
|
||||
|
||||
it('reopens a processing-only override with inherited Standard and keeps the next save partial', async () => {
|
||||
const partialOverride = {
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 3 },
|
||||
},
|
||||
}
|
||||
const reopenedModel = {
|
||||
...editingModel,
|
||||
tiered_pricing: partialOverride,
|
||||
// The current backend returns raw-or-global here, so a partial raw value has no tiers.
|
||||
effective_tiered_pricing: partialOverride,
|
||||
} as Model
|
||||
globalModelMocks.getGlobalModel.mockResolvedValue({
|
||||
id: 'global-model-1',
|
||||
name: 'gpt-test',
|
||||
display_name: 'GPT Test',
|
||||
is_active: true,
|
||||
default_tiered_pricing: editingModel.effective_tiered_pricing,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
total_models: 1,
|
||||
total_providers: 1,
|
||||
price_range: {},
|
||||
})
|
||||
|
||||
mountDialog(reopenedModel)
|
||||
await settle()
|
||||
findButton('Token').click()
|
||||
await nextTick()
|
||||
|
||||
expect(globalModelMocks.getGlobalModel).toHaveBeenCalledWith('global-model-1')
|
||||
expect(document.body.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Standard 阶梯 1 输入价格(美元/百万 Token)"]',
|
||||
)?.value).toBe('5')
|
||||
expect(document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
)?.value).toBe('3')
|
||||
expect(document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-fast"]',
|
||||
)?.value).toBe('2')
|
||||
expect(document.body.querySelector('[data-processing-tier="hyperlane"]')).not.toBeNull()
|
||||
|
||||
const multiplier = document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
)
|
||||
if (!multiplier) throw new Error('Missing OpenAI Fast multiplier')
|
||||
multiplier.value = '4'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
expect(modelMocks.updateModel.mock.calls[0][2].tiered_pricing).toEqual({
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 4 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('reopens and edits an explicit unknown Provider processing tier', async () => {
|
||||
const providerHyperlane = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 9, output_price_per_1m: 54 }],
|
||||
future_overlay_option: 'keep-provider-hyperlane',
|
||||
}
|
||||
const partialOverride = {
|
||||
processing_tiers: {
|
||||
hyperlane: providerHyperlane,
|
||||
},
|
||||
}
|
||||
const reopenedModel = {
|
||||
...editingModel,
|
||||
tiered_pricing: partialOverride,
|
||||
effective_tiered_pricing: partialOverride,
|
||||
} as Model
|
||||
globalModelMocks.getGlobalModel.mockResolvedValue({
|
||||
id: 'global-model-1',
|
||||
name: 'gpt-test',
|
||||
display_name: 'GPT Test',
|
||||
is_active: true,
|
||||
default_tiered_pricing: editingModel.effective_tiered_pricing,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
total_models: 1,
|
||||
total_providers: 1,
|
||||
price_range: {},
|
||||
})
|
||||
|
||||
mountDialog(reopenedModel)
|
||||
await settle()
|
||||
findButton('Token').click()
|
||||
await nextTick()
|
||||
const hyperlane = document.body.querySelector<HTMLButtonElement>(
|
||||
'[data-processing-tier="hyperlane"]',
|
||||
)
|
||||
if (!hyperlane) throw new Error('Missing hyperlane pricing entry')
|
||||
hyperlane.click()
|
||||
await nextTick()
|
||||
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="hyperlane 阶梯 1 输入价格(美元/百万 Token)"]',
|
||||
)
|
||||
if (!input) throw new Error('Missing hyperlane input-price editor')
|
||||
expect(input.value).toBe('9')
|
||||
input.value = '10'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
expect(modelMocks.updateModel.mock.calls[0][2].tiered_pricing).toEqual({
|
||||
processing_tiers: {
|
||||
hyperlane: {
|
||||
...providerHyperlane,
|
||||
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 54 }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('edits the per-request override through the same billing-mode tabs', async () => {
|
||||
mountDialog()
|
||||
await settle()
|
||||
findButton('按次').click()
|
||||
await nextTick()
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="留空使用全局模型默认值"]',
|
||||
)
|
||||
if (!input) throw new Error('Missing per-request price input')
|
||||
|
||||
input.value = '0.5'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
const payload = modelMocks.updateModel.mock.calls[0][2]
|
||||
expect(payload.price_per_request).toBe(0.5)
|
||||
expect(payload).not.toHaveProperty('tiered_pricing')
|
||||
expect(payload).not.toHaveProperty('config')
|
||||
})
|
||||
|
||||
it('enables the Provider image capability when the Image tab is explicitly selected', async () => {
|
||||
mountDialog()
|
||||
await settle()
|
||||
|
||||
findButton('图片').click()
|
||||
await nextTick()
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
const payload = modelMocks.updateModel.mock.calls[0][2]
|
||||
expect(payload.supports_image_generation).toBe(true)
|
||||
expect(payload).not.toHaveProperty('tiered_pricing')
|
||||
expect(payload).not.toHaveProperty('price_per_request')
|
||||
expect(payload).not.toHaveProperty('config')
|
||||
})
|
||||
})
|
||||
+152
-1
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildProviderModelCreatePayload,
|
||||
buildProviderModelUpdatePayload,
|
||||
buildProviderTieredPricingOverride,
|
||||
mergeProviderTieredPricingForEditing,
|
||||
modelSupportsEmbedding,
|
||||
} from '../provider-model-form-helpers'
|
||||
|
||||
@@ -29,7 +31,8 @@ describe('provider model form embedding helpers', () => {
|
||||
providerModelName: 'text-embedding-3-small',
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: false,
|
||||
pricePerRequest: undefined,
|
||||
pricePerRequest: 0.25,
|
||||
pricePerRequestModified: false,
|
||||
cleanConfig: {
|
||||
embedding: true,
|
||||
model_type: 'embedding',
|
||||
@@ -44,6 +47,7 @@ describe('provider model form embedding helpers', () => {
|
||||
global_model_id: 'gm-embedding',
|
||||
provider_model_name: 'text-embedding-3-small',
|
||||
tiered_pricing: undefined,
|
||||
price_per_request: undefined,
|
||||
config: undefined,
|
||||
supports_streaming: false,
|
||||
})
|
||||
@@ -57,6 +61,7 @@ describe('provider model form embedding helpers', () => {
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: false,
|
||||
pricePerRequest: undefined,
|
||||
pricePerRequestModified: false,
|
||||
cleanConfig: undefined,
|
||||
configTouched: false,
|
||||
isActive: true,
|
||||
@@ -75,13 +80,16 @@ describe('provider model form embedding helpers', () => {
|
||||
it('preserves edited provider embedding config without posting unsupported embedding controls', () => {
|
||||
const payload = buildProviderModelUpdatePayload({
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: true,
|
||||
pricePerRequest: undefined,
|
||||
pricePerRequestModified: false,
|
||||
cleanConfig: {
|
||||
streaming: false,
|
||||
embedding: true,
|
||||
model_type: 'embedding',
|
||||
api_formats: ['gemini:embedding'],
|
||||
},
|
||||
configTouched: true,
|
||||
supportsStreaming: false,
|
||||
isActive: true,
|
||||
})
|
||||
@@ -92,7 +100,150 @@ describe('provider model form embedding helpers', () => {
|
||||
model_type: 'embedding',
|
||||
api_formats: ['gemini:embedding'],
|
||||
})
|
||||
expect(payload.tiered_pricing).toEqual(pricing)
|
||||
expect(payload.supports_streaming).toBe(false)
|
||||
expect('supports_embedding' in payload).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps inherited pricing and config out of an unchanged provider update', () => {
|
||||
const payload = buildProviderModelUpdatePayload({
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: false,
|
||||
pricePerRequest: 0.25,
|
||||
pricePerRequestModified: false,
|
||||
cleanConfig: { billing: { video: { price_per_second_by_resolution: { '720p': 0.1 } } } },
|
||||
configTouched: false,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(payload).not.toHaveProperty('tiered_pricing')
|
||||
expect(payload).not.toHaveProperty('price_per_request')
|
||||
expect(payload).not.toHaveProperty('config')
|
||||
})
|
||||
|
||||
it('writes an explicitly edited per-request price and supports clearing it', () => {
|
||||
const edited = buildProviderModelUpdatePayload({
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: false,
|
||||
pricePerRequest: 0.5,
|
||||
pricePerRequestModified: true,
|
||||
cleanConfig: undefined,
|
||||
configTouched: false,
|
||||
isActive: true,
|
||||
})
|
||||
const cleared = buildProviderModelUpdatePayload({
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: false,
|
||||
pricePerRequest: undefined,
|
||||
pricePerRequestModified: true,
|
||||
cleanConfig: undefined,
|
||||
configTouched: false,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(edited.price_per_request).toBe(0.5)
|
||||
expect(cleared.price_per_request).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('provider model pricing override helpers', () => {
|
||||
const inheritedPricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
future_global_option: 'inherit-only',
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 2.5 },
|
||||
fast: { price_multiplier: 2 },
|
||||
},
|
||||
}
|
||||
|
||||
it('projects a processing-tier edit without freezing inherited Standard or other tiers', () => {
|
||||
const finalPricing = structuredClone(inheritedPricing)
|
||||
finalPricing.processing_tiers.priority.price_multiplier = 3
|
||||
|
||||
const override = buildProviderTieredPricingOverride(
|
||||
finalPricing,
|
||||
inheritedPricing,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(override).toEqual({
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 3 },
|
||||
},
|
||||
})
|
||||
expect(override).not.toHaveProperty('tiers')
|
||||
expect(override?.processing_tiers).not.toHaveProperty('fast')
|
||||
expect(override).not.toHaveProperty('future_global_option')
|
||||
|
||||
const createPayload = buildProviderModelCreatePayload({
|
||||
globalModelId: 'global-model-1',
|
||||
providerModelName: 'gpt-test',
|
||||
finalTieredPricing: override,
|
||||
tieredPricingModified: true,
|
||||
pricePerRequestModified: false,
|
||||
configTouched: false,
|
||||
isActive: true,
|
||||
})
|
||||
expect(createPayload.tiered_pricing).toEqual(override)
|
||||
expect(createPayload.tiered_pricing).not.toHaveProperty('tiers')
|
||||
})
|
||||
|
||||
it('keeps an existing Provider Standard override while adding only the edited tier', () => {
|
||||
const providerStandard = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 7, output_price_per_1m: 42 }],
|
||||
provider_contract: 'keep-provider-standard',
|
||||
}
|
||||
const editorPricing = {
|
||||
...structuredClone(providerStandard),
|
||||
processing_tiers: structuredClone(inheritedPricing.processing_tiers),
|
||||
}
|
||||
const finalPricing = structuredClone(editorPricing)
|
||||
finalPricing.processing_tiers.priority.price_multiplier = 3
|
||||
|
||||
const override = buildProviderTieredPricingOverride(
|
||||
finalPricing,
|
||||
editorPricing,
|
||||
providerStandard,
|
||||
)
|
||||
|
||||
expect(override).toEqual({
|
||||
...providerStandard,
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 3 },
|
||||
},
|
||||
})
|
||||
expect(override?.processing_tiers).not.toHaveProperty('fast')
|
||||
})
|
||||
|
||||
it('merges a saved processing-only override for editing and stays partial on the next save', () => {
|
||||
const savedOverride = {
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 3 },
|
||||
},
|
||||
}
|
||||
const reopenedEditorPricing = mergeProviderTieredPricingForEditing(
|
||||
inheritedPricing,
|
||||
savedOverride,
|
||||
)
|
||||
|
||||
expect(reopenedEditorPricing).toEqual({
|
||||
...inheritedPricing,
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 3 },
|
||||
fast: { price_multiplier: 2 },
|
||||
},
|
||||
})
|
||||
|
||||
const finalPricing = structuredClone(reopenedEditorPricing!)
|
||||
finalPricing.processing_tiers!.priority.price_multiplier = 4
|
||||
expect(buildProviderTieredPricingOverride(
|
||||
finalPricing,
|
||||
reopenedEditorPricing,
|
||||
savedOverride,
|
||||
)).toEqual({
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 4 },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ModelCreate, ModelUpdate, TieredPricingConfig } from '@/api/endpoints'
|
||||
import type {
|
||||
ModelCreate,
|
||||
ModelUpdate,
|
||||
ProviderTieredPricingConfig,
|
||||
TieredPricingConfig,
|
||||
} from '@/api/endpoints'
|
||||
|
||||
interface EmbeddingMetadataCarrier {
|
||||
supported_capabilities?: string[] | null
|
||||
@@ -15,9 +20,10 @@ function isEmbeddingApiFormat(format: unknown): boolean {
|
||||
export interface ProviderModelCreatePayloadInput {
|
||||
globalModelId: string
|
||||
providerModelName: string
|
||||
finalTieredPricing: TieredPricingConfig | null
|
||||
finalTieredPricing: ProviderTieredPricingConfig | null
|
||||
tieredPricingModified: boolean
|
||||
pricePerRequest?: number
|
||||
pricePerRequestModified: boolean
|
||||
cleanConfig?: Record<string, unknown>
|
||||
configTouched: boolean
|
||||
supportsVision?: boolean
|
||||
@@ -29,9 +35,12 @@ export interface ProviderModelCreatePayloadInput {
|
||||
}
|
||||
|
||||
export interface ProviderModelUpdatePayloadInput {
|
||||
finalTieredPricing: TieredPricingConfig | null
|
||||
finalTieredPricing: ProviderTieredPricingConfig | null
|
||||
tieredPricingModified: boolean
|
||||
pricePerRequest?: number
|
||||
pricePerRequestModified: boolean
|
||||
cleanConfig?: Record<string, unknown>
|
||||
configTouched: boolean
|
||||
supportsVision?: boolean
|
||||
supportsFunctionCalling?: boolean
|
||||
supportsStreaming?: boolean
|
||||
@@ -53,12 +62,180 @@ export function modelSupportsEmbedding(model: EmbeddingMetadataCarrier | null |
|
||||
|| (Array.isArray(config.api_formats) && config.api_formats.some(isEmbeddingApiFormat))
|
||||
}
|
||||
|
||||
const STANDARD_PRICING_KEYS = new Set([
|
||||
'tiers',
|
||||
'image_output_prices',
|
||||
'image_output_price_default',
|
||||
'image_output_price_ranges',
|
||||
'image_output_price_per_image',
|
||||
'image_output_price_matrix',
|
||||
'image_prices',
|
||||
])
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
|
||||
function hasOwn(object: object, key: PropertyKey): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(object, key)
|
||||
}
|
||||
|
||||
function valueHasEntries(value: unknown): boolean {
|
||||
return (Array.isArray(value) && value.length > 0)
|
||||
|| (isRecord(value) && Object.keys(value).length > 0)
|
||||
}
|
||||
|
||||
function hasStandardPricingData(pricing: ProviderTieredPricingConfig): boolean {
|
||||
return (Array.isArray(pricing.tiers) && pricing.tiers.length > 0)
|
||||
|| (typeof pricing.image_output_price_default === 'number'
|
||||
&& Number.isFinite(pricing.image_output_price_default))
|
||||
|| [
|
||||
'image_output_prices',
|
||||
'image_output_price_ranges',
|
||||
'image_output_price_per_image',
|
||||
'image_output_price_matrix',
|
||||
'image_prices',
|
||||
].some(key => valueHasEntries(pricing[key]))
|
||||
}
|
||||
|
||||
function pricingRoot(pricing: ProviderTieredPricingConfig): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(pricing).filter(([key]) => key !== 'processing_tiers'),
|
||||
)
|
||||
}
|
||||
|
||||
function processingTierEntries(pricing: ProviderTieredPricingConfig | null | undefined) {
|
||||
return isRecord(pricing?.processing_tiers)
|
||||
? Object.entries(pricing.processing_tiers)
|
||||
: []
|
||||
}
|
||||
|
||||
function jsonValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left)
|
||||
&& Array.isArray(right)
|
||||
&& left.length === right.length
|
||||
&& left.every((value, index) => jsonValuesEqual(value, right[index]))
|
||||
}
|
||||
if (!isRecord(left) || !isRecord(right)) return false
|
||||
const leftKeys = Object.keys(left).sort()
|
||||
const rightKeys = Object.keys(right).sort()
|
||||
return leftKeys.length === rightKeys.length
|
||||
&& leftKeys.every((key, index) => (
|
||||
key === rightKeys[index]
|
||||
&& jsonValuesEqual(left[key], right[key])
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete catalog shown by the editor from the two independent
|
||||
* runtime sources: GlobalModel Standard/default overlays and Provider overrides.
|
||||
*/
|
||||
export function mergeProviderTieredPricingForEditing(
|
||||
globalDefault: ProviderTieredPricingConfig | null | undefined,
|
||||
providerOverride: ProviderTieredPricingConfig | null | undefined,
|
||||
): TieredPricingConfig | null {
|
||||
if (!providerOverride) {
|
||||
return Array.isArray(globalDefault?.tiers)
|
||||
? cloneJson(globalDefault) as TieredPricingConfig
|
||||
: null
|
||||
}
|
||||
|
||||
const providerHasStandard = hasStandardPricingData(providerOverride)
|
||||
const providerRoot = pricingRoot(providerOverride)
|
||||
let mergedRoot: Record<string, unknown>
|
||||
if (providerHasStandard) {
|
||||
mergedRoot = providerRoot
|
||||
} else if (globalDefault) {
|
||||
const providerMetadata = Object.fromEntries(
|
||||
Object.entries(providerRoot).filter(([key]) => !STANDARD_PRICING_KEYS.has(key)),
|
||||
)
|
||||
mergedRoot = {
|
||||
...pricingRoot(globalDefault),
|
||||
...providerMetadata,
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!Array.isArray(mergedRoot.tiers)) return null
|
||||
|
||||
const mergedProcessingTiers = Object.fromEntries([
|
||||
...processingTierEntries(globalDefault),
|
||||
...processingTierEntries(providerOverride),
|
||||
])
|
||||
if (Object.keys(mergedProcessingTiers).length > 0) {
|
||||
mergedRoot.processing_tiers = mergedProcessingTiers
|
||||
}
|
||||
return cloneJson(mergedRoot) as TieredPricingConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the complete editor catalog back to the smallest Provider override.
|
||||
* Unchanged Standard and processing-tier values keep inheriting from GlobalModel.
|
||||
*/
|
||||
export function buildProviderTieredPricingOverride(
|
||||
finalPricing: TieredPricingConfig | null,
|
||||
originalEditorPricing: TieredPricingConfig | null,
|
||||
originalProviderOverride: ProviderTieredPricingConfig | null | undefined,
|
||||
): ProviderTieredPricingConfig | null {
|
||||
if (!finalPricing) return null
|
||||
if (!originalEditorPricing) return cloneJson(finalPricing)
|
||||
|
||||
const originalProcessingTiers = originalProviderOverride?.processing_tiers
|
||||
const preservedProcessingTiers = hasOwn(originalProviderOverride || {}, 'processing_tiers')
|
||||
? originalProcessingTiers === undefined
|
||||
? undefined
|
||||
: cloneJson(originalProcessingTiers)
|
||||
: undefined
|
||||
let result = cloneJson(originalProviderOverride || {})
|
||||
|
||||
if (!jsonValuesEqual(pricingRoot(finalPricing), pricingRoot(originalEditorPricing))) {
|
||||
result = cloneJson(pricingRoot(finalPricing)) as ProviderTieredPricingConfig
|
||||
if (preservedProcessingTiers !== undefined || originalProcessingTiers === null) {
|
||||
result.processing_tiers = preservedProcessingTiers ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const finalProcessingTiers = new Map(processingTierEntries(finalPricing))
|
||||
const baselineProcessingTiers = new Map(processingTierEntries(originalEditorPricing))
|
||||
const nextProcessingTiers = new Map(processingTierEntries(result))
|
||||
let processingTiersChanged = false
|
||||
const keys = new Set([
|
||||
...finalProcessingTiers.keys(),
|
||||
...baselineProcessingTiers.keys(),
|
||||
])
|
||||
for (const key of keys) {
|
||||
const finalOverlay = finalProcessingTiers.get(key)
|
||||
const baselineOverlay = baselineProcessingTiers.get(key)
|
||||
if (jsonValuesEqual(finalOverlay, baselineOverlay)) continue
|
||||
processingTiersChanged = true
|
||||
if (finalOverlay === undefined) nextProcessingTiers.delete(key)
|
||||
else nextProcessingTiers.set(key, cloneJson(finalOverlay))
|
||||
}
|
||||
|
||||
if (processingTiersChanged) {
|
||||
if (nextProcessingTiers.size > 0) {
|
||||
result.processing_tiers = Object.fromEntries(nextProcessingTiers)
|
||||
} else {
|
||||
delete result.processing_tiers
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
|
||||
export function buildProviderModelCreatePayload(input: ProviderModelCreatePayloadInput): ModelCreate {
|
||||
return {
|
||||
global_model_id: input.globalModelId,
|
||||
provider_model_name: input.providerModelName,
|
||||
tiered_pricing: input.tieredPricingModified && input.finalTieredPricing ? input.finalTieredPricing : undefined,
|
||||
price_per_request: input.pricePerRequest,
|
||||
price_per_request: input.pricePerRequestModified ? input.pricePerRequest : undefined,
|
||||
config: input.configTouched ? input.cleanConfig : undefined,
|
||||
supports_vision: input.supportsVision,
|
||||
supports_function_calling: input.supportsFunctionCalling,
|
||||
@@ -71,9 +248,11 @@ export function buildProviderModelCreatePayload(input: ProviderModelCreatePayloa
|
||||
|
||||
export function buildProviderModelUpdatePayload(input: ProviderModelUpdatePayloadInput): ModelUpdate {
|
||||
return {
|
||||
tiered_pricing: input.finalTieredPricing,
|
||||
price_per_request: input.pricePerRequest ?? null,
|
||||
config: input.cleanConfig || null,
|
||||
...(input.tieredPricingModified ? { tiered_pricing: input.finalTieredPricing } : {}),
|
||||
...(input.pricePerRequestModified
|
||||
? { price_per_request: input.pricePerRequest ?? null }
|
||||
: {}),
|
||||
...(input.configTouched ? { config: input.cleanConfig || null } : {}),
|
||||
supports_vision: input.supportsVision,
|
||||
supports_function_calling: input.supportsFunctionCalling,
|
||||
supports_streaming: input.supportsStreaming,
|
||||
|
||||
Reference in New Issue
Block a user