Merge remote-tracking branch 'origin/main' into codex/pool-key-bulk-management-20260714

This commit is contained in:
MMEXA
2026-07-17 00:17:02 +08:00
40 changed files with 5012 additions and 565 deletions
@@ -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,
@@ -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')
})
})
@@ -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,