mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix: display multiplier-only interval prices
This commit is contained in:
@@ -81,15 +81,19 @@ type userSupportedModelPricing struct {
|
||||
|
||||
// userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。
|
||||
type userPricingIntervalDTO struct {
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label,omitempty"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label,omitempty"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
InputMultiplier *float64 `json:"input_multiplier"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier"`
|
||||
CacheWriteMultiplier *float64 `json:"cache_write_multiplier"`
|
||||
CacheReadMultiplier *float64 `json:"cache_read_multiplier"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
}
|
||||
|
||||
// userSupportedModel 用户可见的支持模型条目。
|
||||
@@ -295,15 +299,19 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval
|
||||
intervals := make([]userPricingIntervalDTO, 0, len(src))
|
||||
for _, iv := range src {
|
||||
intervals = append(intervals, userPricingIntervalDTO{
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: iv.InputMultiplier,
|
||||
OutputMultiplier: iv.OutputMultiplier,
|
||||
CacheWriteMultiplier: iv.CacheWriteMultiplier,
|
||||
CacheReadMultiplier: iv.CacheReadMultiplier,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
})
|
||||
}
|
||||
return intervals
|
||||
|
||||
@@ -113,10 +113,18 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
||||
}
|
||||
|
||||
// pricing interval 白名单:不应暴露 id / sort_order。
|
||||
inputMultiplier := 2.0
|
||||
outputMultiplier := 1.5
|
||||
cacheWriteMultiplier := 2.0
|
||||
cacheReadMultiplier := 2.0
|
||||
pricing := toUserPricing(&service.ChannelModelPricing{
|
||||
BillingMode: service.BillingModeToken,
|
||||
Intervals: []service.PricingInterval{
|
||||
{ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3},
|
||||
{
|
||||
ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3,
|
||||
InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier,
|
||||
CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NotNil(t, pricing)
|
||||
@@ -129,6 +137,14 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
||||
_, exists := ivDecoded[key]
|
||||
require.Falsef(t, exists, "user pricing interval must not expose %q", key)
|
||||
}
|
||||
for key, want := range map[string]float64{
|
||||
"input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier,
|
||||
"cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier,
|
||||
} {
|
||||
got, exists := ivDecoded[key]
|
||||
require.Truef(t, exists, "user pricing interval must expose %q", key)
|
||||
require.InDelta(t, want, got.(float64), 1e-12)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
|
||||
|
||||
@@ -31,6 +31,10 @@ export interface UserPricingInterval {
|
||||
cache_write_price: number | null
|
||||
cache_write_1h_price?: number | null
|
||||
cache_read_price: number | null
|
||||
input_multiplier?: number | null
|
||||
output_multiplier?: number | null
|
||||
cache_write_multiplier?: number | null
|
||||
cache_read_multiplier?: number | null
|
||||
per_request_price: number | null
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface PlazaTimePricing {
|
||||
export interface PlazaModel {
|
||||
name: string
|
||||
platform: string
|
||||
/** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算);均为标准时段价。 */
|
||||
/** 实收口径的展示定价:档位可提供绝对单价或相对基础价倍率;均为标准时段价。 */
|
||||
pricing: UserSupportedModelPricing | null
|
||||
official_pricing: PlazaOfficialPricing | null
|
||||
/** 仅多档模型返回。 */
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
<template v-if="iv.tier_label">{{ iv.tier_label }}</template>
|
||||
<template v-else>{{ formatRange(iv.min_tokens, iv.max_tokens) }}</template>
|
||||
</span>
|
||||
<span>{{ formatInterval(iv, model.pricing.billing_mode) }}</span>
|
||||
<span>{{ formatInterval(iv, model.pricing) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,12 +172,11 @@ import { formatScaled } from '@/utils/pricing'
|
||||
import {
|
||||
BILLING_MODE_TOKEN,
|
||||
BILLING_MODE_PER_REQUEST,
|
||||
BILLING_MODE_IMAGE,
|
||||
type BillingMode
|
||||
BILLING_MODE_IMAGE
|
||||
} from '@/constants/channel'
|
||||
// 复用 api/channels.ts 的用户侧最小形态 DTO。
|
||||
// admin 侧 ChannelModelPricing 字段更多,但结构上是用户 DTO 的超集,admin 视图传入可直接通过结构化子类型检查。
|
||||
import type { UserPricingInterval, UserSupportedModel } from '@/api/channels'
|
||||
import type { UserPricingInterval, UserSupportedModel, UserSupportedModelPricing } from '@/api/channels'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import type { GroupPlatform } from '@/types'
|
||||
import { platformBadgeClass, platformBorderClass, platformBadgeLightClass } from '@/utils/platformColors'
|
||||
@@ -246,12 +245,22 @@ function formatRange(min: number, max: number | null): string {
|
||||
return `(${min}, ${maxLabel}]`
|
||||
}
|
||||
|
||||
function formatInterval(iv: UserPricingInterval, mode: BillingMode): string {
|
||||
if (mode === BILLING_MODE_PER_REQUEST || mode === BILLING_MODE_IMAGE) {
|
||||
function effectiveIntervalPrice(
|
||||
price: number | null | undefined,
|
||||
multiplier: number | null | undefined,
|
||||
basePrice: number | null
|
||||
): number | null {
|
||||
if (price != null) return price
|
||||
if (multiplier != null && basePrice != null) return basePrice * multiplier
|
||||
return basePrice
|
||||
}
|
||||
|
||||
function formatInterval(iv: UserPricingInterval, pricing: UserSupportedModelPricing): string {
|
||||
if (pricing.billing_mode === BILLING_MODE_PER_REQUEST || pricing.billing_mode === BILLING_MODE_IMAGE) {
|
||||
return formatScaled(iv.per_request_price, 1)
|
||||
}
|
||||
const input = formatScaled(iv.input_price, perMillionScale)
|
||||
const output = formatScaled(iv.output_price, perMillionScale)
|
||||
const input = formatScaled(effectiveIntervalPrice(iv.input_price, iv.input_multiplier, pricing.input_price), perMillionScale)
|
||||
const output = formatScaled(effectiveIntervalPrice(iv.output_price, iv.output_multiplier, pricing.output_price), perMillionScale)
|
||||
return `${input} / ${output}`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SupportedModelChip from '../SupportedModelChip.vue'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}
|
||||
})
|
||||
|
||||
describe('SupportedModelChip', () => {
|
||||
it('仅配置区间倍率时按基础价展示 token 档位', async () => {
|
||||
const wrapper = mount(SupportedModelChip, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
model: {
|
||||
name: 'gpt-test',
|
||||
platform: '',
|
||||
pricing: {
|
||||
billing_mode: 'token',
|
||||
input_price: 10e-6,
|
||||
output_price: 50e-6,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: [{
|
||||
min_tokens: 272000,
|
||||
max_tokens: null,
|
||||
input_price: null,
|
||||
output_price: null,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
input_multiplier: 2,
|
||||
output_multiplier: 1.5,
|
||||
per_request_price: null
|
||||
}]
|
||||
}
|
||||
},
|
||||
showPlatform: false
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('[tabindex="0"]').trigger('mouseenter')
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('$20 / $75')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -119,7 +119,7 @@
|
||||
class="whitespace-nowrap text-xs leading-5"
|
||||
>
|
||||
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500" :title="tierHint(m)">{{ tierLabel(iv) }}</span>
|
||||
{{ paidPerMillion(iv.input_price, period) }}
|
||||
{{ paidPerMillion(effectiveIntervalPrice(iv.input_price, iv.input_multiplier, m.pricing?.input_price), period) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ paidPerMillion(m.pricing?.input_price, period) }}</template>
|
||||
@@ -132,7 +132,7 @@
|
||||
class="whitespace-nowrap text-xs leading-5"
|
||||
:title="tierHint(m)"
|
||||
>
|
||||
{{ paidPerMillion(iv.output_price, period) }}
|
||||
{{ paidPerMillion(effectiveIntervalPrice(iv.output_price, iv.output_multiplier, m.pricing?.output_price), period) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ paidPerMillion(m.pricing?.output_price, period) }}</template>
|
||||
@@ -145,15 +145,15 @@
|
||||
class="whitespace-nowrap font-mono text-xs leading-5 text-gray-800 dark:text-gray-200"
|
||||
:title="tierHint(m)"
|
||||
>
|
||||
<template v-if="iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null">
|
||||
<template v-if="iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null || iv.cache_write_multiplier != null || iv.cache_read_multiplier != null">
|
||||
<span class="font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
|
||||
{{ paidPerMillion(iv.cache_write_price, period) }}
|
||||
<template v-if="iv.cache_write_1h_price != null"
|
||||
><span class="font-sans font-normal text-gray-400 dark:text-dark-500"> (1h </span>{{ paidPerMillion(iv.cache_write_1h_price, period)
|
||||
{{ paidPerMillion(effectiveIntervalPrice(iv.cache_write_price, iv.cache_write_multiplier, m.pricing?.cache_write_price), period) }}
|
||||
<template v-if="effectiveIntervalPrice(iv.cache_write_1h_price, iv.cache_write_multiplier, m.pricing?.cache_write_1h_price) != null"
|
||||
><span class="font-sans font-normal text-gray-400 dark:text-dark-500"> (1h </span>{{ paidPerMillion(effectiveIntervalPrice(iv.cache_write_1h_price, iv.cache_write_multiplier, m.pricing?.cache_write_1h_price), period)
|
||||
}}<span class="font-sans font-normal text-gray-400 dark:text-dark-500">)</span></template
|
||||
>
|
||||
<span class="ml-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheReadShort') }}</span>
|
||||
{{ paidPerMillion(iv.cache_read_price, period) }}
|
||||
{{ paidPerMillion(effectiveIntervalPrice(iv.cache_read_price, iv.cache_read_multiplier, m.pricing?.cache_read_price), period) }}
|
||||
</template>
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
</div>
|
||||
@@ -217,7 +217,7 @@
|
||||
class="whitespace-nowrap leading-5"
|
||||
>
|
||||
<span class="mr-1 font-sans text-gray-400 dark:text-dark-500" :title="t('modelPlaza.table.tierHint')">{{ tierLabel(iv) }}</span>
|
||||
{{ official(iv.input_price) }}
|
||||
{{ official(effectiveIntervalPrice(iv.input_price, iv.input_multiplier, m.official_pricing?.input_price)) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ official(m.official_pricing?.input_price) }}</template>
|
||||
@@ -230,7 +230,7 @@
|
||||
class="whitespace-nowrap leading-5"
|
||||
:title="t('modelPlaza.table.tierHint')"
|
||||
>
|
||||
{{ official(iv.output_price) }}
|
||||
{{ official(effectiveIntervalPrice(iv.output_price, iv.output_multiplier, m.official_pricing?.output_price)) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>{{ official(m.official_pricing?.output_price) }}</template>
|
||||
@@ -243,15 +243,15 @@
|
||||
class="whitespace-nowrap font-mono text-xs leading-5 text-gray-500 dark:text-dark-400"
|
||||
:title="t('modelPlaza.table.tierHint')"
|
||||
>
|
||||
<template v-if="iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null">
|
||||
<template v-if="iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null || iv.cache_write_multiplier != null || iv.cache_read_multiplier != null">
|
||||
<span class="font-sans text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
|
||||
{{ official(iv.cache_write_price) }}
|
||||
<template v-if="iv.cache_write_1h_price != null"
|
||||
><span class="font-sans text-gray-400 dark:text-dark-500"> (1h </span>{{ official(iv.cache_write_1h_price)
|
||||
{{ official(effectiveIntervalPrice(iv.cache_write_price, iv.cache_write_multiplier, m.official_pricing?.cache_write_price)) }}
|
||||
<template v-if="effectiveIntervalPrice(iv.cache_write_1h_price, iv.cache_write_multiplier, m.official_pricing?.cache_write_1h_price) != null"
|
||||
><span class="font-sans text-gray-400 dark:text-dark-500"> (1h </span>{{ official(effectiveIntervalPrice(iv.cache_write_1h_price, iv.cache_write_multiplier, m.official_pricing?.cache_write_1h_price))
|
||||
}}<span class="font-sans text-gray-400 dark:text-dark-500">)</span></template
|
||||
>
|
||||
<span class="ml-1 font-sans text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheReadShort') }}</span>
|
||||
{{ official(iv.cache_read_price) }}
|
||||
{{ official(effectiveIntervalPrice(iv.cache_read_price, iv.cache_read_multiplier, m.official_pricing?.cache_read_price)) }}
|
||||
</template>
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
</div>
|
||||
@@ -434,6 +434,16 @@ function official(value: number | null | undefined): string {
|
||||
return formatScaled(value, PER_MILLION, MIN_DECIMALS)
|
||||
}
|
||||
|
||||
function effectiveIntervalPrice(
|
||||
price: number | null | undefined,
|
||||
multiplier: number | null | undefined,
|
||||
basePrice: number | null | undefined
|
||||
): number | null | undefined {
|
||||
if (price != null) return price
|
||||
if (multiplier != null && basePrice != null) return basePrice * multiplier
|
||||
return basePrice
|
||||
}
|
||||
|
||||
/** 非 token 计费的单位后缀:按图片 → “/ 张”,按次 → “/ 次”。 */
|
||||
function perUnitSuffix(m: PlazaModel): string {
|
||||
return billingMode(m) === BILLING_MODE_IMAGE
|
||||
@@ -495,7 +505,10 @@ function officialIntervals(m: PlazaModel): UserPricingInterval[] {
|
||||
|
||||
/** 任一档带缓存价才按档渲染缓存列;否则沿用平价的写入/读取两行。 */
|
||||
function hasTierCachePricing(intervals: UserPricingInterval[]): boolean {
|
||||
return intervals.some((iv) => iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null)
|
||||
return intervals.some((iv) =>
|
||||
iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null ||
|
||||
iv.cache_write_multiplier != null || iv.cache_read_multiplier != null
|
||||
)
|
||||
}
|
||||
|
||||
/** 档位说明:整单按档计价,或(平台旧规则)仅超出部分按档计价。 */
|
||||
|
||||
@@ -294,6 +294,44 @@ describe('PlazaModelPricingTable', () => {
|
||||
expect(text).toContain('$15.00')
|
||||
})
|
||||
|
||||
it('仅配置区间倍率时按基础价格展示 input/output/cache 各档价格', () => {
|
||||
const model = tokenModel({
|
||||
pricing: {
|
||||
billing_mode: 'token',
|
||||
input_price: 10e-6,
|
||||
output_price: 50e-6,
|
||||
cache_write_price: 12.5e-6,
|
||||
cache_write_1h_price: 12.5e-6,
|
||||
cache_read_price: 2e-6,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
intervals: [{
|
||||
min_tokens: 272000,
|
||||
max_tokens: null,
|
||||
tier_label: '>272K',
|
||||
input_price: null,
|
||||
output_price: null,
|
||||
cache_write_price: null,
|
||||
cache_write_1h_price: null,
|
||||
cache_read_price: null,
|
||||
input_multiplier: 2,
|
||||
output_multiplier: 1.5,
|
||||
cache_write_multiplier: 2,
|
||||
cache_read_multiplier: 2,
|
||||
per_request_price: null
|
||||
}]
|
||||
},
|
||||
official_pricing: null
|
||||
})
|
||||
|
||||
const text = mountTable([model], 1).text()
|
||||
expect(text).toContain('$20.00')
|
||||
expect(text).toContain('$75.00')
|
||||
expect(text).toContain('$25.00')
|
||||
expect(text).toContain('$4.00')
|
||||
})
|
||||
|
||||
it('生图独立倍率开启时,按图价格 × 独立倍率,不乘分组倍率;倍率列展示独立倍率', () => {
|
||||
const model = tokenModel({
|
||||
name: 'gpt-image-2',
|
||||
|
||||
Reference in New Issue
Block a user