mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix: preserve plaza 1h pricing and align interval fallbacks
This commit is contained in:
@@ -19,13 +19,14 @@ const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
|
||||
// ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。
|
||||
// nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。
|
||||
type ContextPricingTier struct {
|
||||
MinTokens int
|
||||
MaxTokens *int
|
||||
Label string
|
||||
Input *float64
|
||||
Output *float64
|
||||
CacheWrite *float64
|
||||
CacheRead *float64
|
||||
MinTokens int
|
||||
MaxTokens *int
|
||||
Label string
|
||||
Input *float64
|
||||
Output *float64
|
||||
CacheWrite *float64
|
||||
CacheWrite1h *float64
|
||||
CacheRead *float64
|
||||
}
|
||||
|
||||
// TimePricingPeriod 分时倍率时段:配置时区当天 [StartTime, EndTime) 内整单费用乘 Multiplier。
|
||||
@@ -289,6 +290,10 @@ func probeContextTier(seg contextSegment, resolved *ResolvedPricing, probe func(
|
||||
return tier, err
|
||||
}
|
||||
// 输出价只随上下文所在档变化:固定上下文 c,对输出 token 数做差商(固定部分相减抵消)。
|
||||
tier.CacheWrite1h, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheCreationTokens: n, CacheCreation1hTokens: n} }, c, delta, probe)
|
||||
if err != nil {
|
||||
return tier, err
|
||||
}
|
||||
tier.Output, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: c, OutputTokens: n} }, 0, contextProbeDelta, probe)
|
||||
if err != nil {
|
||||
return tier, err
|
||||
@@ -298,6 +303,7 @@ func probeContextTier(seg contextSegment, resolved *ResolvedPricing, probe func(
|
||||
tier.Input = contextPricePtr(tier.Input, explicit.input)
|
||||
tier.Output = contextPricePtr(tier.Output, explicit.output)
|
||||
tier.CacheWrite = contextPricePtr(tier.CacheWrite, explicit.cacheWrite)
|
||||
tier.CacheWrite1h = contextPricePtr(tier.CacheWrite1h, explicit.cacheWrite1h)
|
||||
tier.CacheRead = contextPricePtr(tier.CacheRead, explicit.cacheRead)
|
||||
return tier, nil
|
||||
}
|
||||
@@ -341,7 +347,7 @@ func roundContextPrice(v float64) float64 {
|
||||
}
|
||||
|
||||
type explicitContextFields struct {
|
||||
input, output, cacheWrite, cacheRead bool
|
||||
input, output, cacheWrite, cacheWrite1h, cacheRead bool
|
||||
}
|
||||
|
||||
// explicitContextPricingFields 判断各项是否被分组卡/渠道定价(含命中区间)显式配置。
|
||||
@@ -355,11 +361,13 @@ func explicitContextPricingFields(resolved *ResolvedPricing, contextTokens int)
|
||||
out.input = cp.InputPrice != nil
|
||||
out.output = cp.OutputPrice != nil
|
||||
out.cacheWrite = cp.CacheWritePrice != nil
|
||||
out.cacheWrite1h = cp.CacheWrite1hPrice != nil || cp.CacheWritePrice != nil
|
||||
out.cacheRead = cp.CacheReadPrice != nil
|
||||
if iv := FindMatchingInterval(resolved.Intervals, contextTokens); iv != nil {
|
||||
out.input = out.input || iv.InputPrice != nil
|
||||
out.output = out.output || iv.OutputPrice != nil
|
||||
out.cacheWrite = out.cacheWrite || iv.CacheWritePrice != nil
|
||||
out.cacheWrite1h = out.cacheWrite1h || iv.CacheWrite1hPrice != nil || iv.CacheWritePrice != nil
|
||||
out.cacheRead = out.cacheRead || iv.CacheReadPrice != nil
|
||||
}
|
||||
return out
|
||||
@@ -393,7 +401,7 @@ func mergeEqualContextTiers(tiers []ContextPricingTier) []ContextPricingTier {
|
||||
|
||||
func sameContextPrices(a, b ContextPricingTier) bool {
|
||||
return samePricePtr(a.Input, b.Input) && samePricePtr(a.Output, b.Output) &&
|
||||
samePricePtr(a.CacheWrite, b.CacheWrite) && samePricePtr(a.CacheRead, b.CacheRead)
|
||||
samePricePtr(a.CacheWrite, b.CacheWrite) && samePricePtr(a.CacheWrite1h, b.CacheWrite1h) && samePricePtr(a.CacheRead, b.CacheRead)
|
||||
}
|
||||
|
||||
func samePricePtr(a, b *float64) bool {
|
||||
|
||||
@@ -385,6 +385,24 @@ func TestResolveContextPricingSchedule_NilResolver(t *testing.T) {
|
||||
require.Nil(t, sched)
|
||||
}
|
||||
|
||||
func TestPlazaSchedulePreservesCacheWrite1h(t *testing.T) {
|
||||
cp := sonnetChannel(PricingInterval{MinTokens: 272000, CacheWriteMultiplier: testPtrFloat64(2)})
|
||||
cp[0].CacheWritePrice = testPtrFloat64(12.5e-6)
|
||||
cp[0].CacheWrite1hPrice = testPtrFloat64(20e-6)
|
||||
group := enabledGroup(PlatformAnthropic)
|
||||
bs, resolver := newScheduleTestEnv(t, scheduleScenario{channel: cp, groupPlatform: PlatformAnthropic})
|
||||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic})
|
||||
require.NoError(t, err)
|
||||
display := plazaPricingFromSchedule(&cp[0], sched)
|
||||
requirePrice(t, testPtrFloat64(20e-6), display.CacheWrite1hPrice, "base 1h")
|
||||
require.Len(t, display.Intervals, 2)
|
||||
requirePrice(t, testPtrFloat64(25e-6), display.Intervals[1].CacheWritePrice, "tier 5m")
|
||||
requirePrice(t, testPtrFloat64(40e-6), display.Intervals[1].CacheWrite1hPrice, "tier 1h")
|
||||
a := ContextPricingTier{CacheWrite1h: testPtrFloat64(1)}
|
||||
b := ContextPricingTier{CacheWrite1h: testPtrFloat64(2)}
|
||||
require.Len(t, mergeEqualContextTiers([]ContextPricingTier{a, b}), 2)
|
||||
}
|
||||
|
||||
// --- 对账:阶梯表推算的费用必须等于真实计费函数 ---
|
||||
|
||||
type tokenKind int
|
||||
|
||||
@@ -269,6 +269,7 @@ func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSch
|
||||
out.InputPrice = first.Input
|
||||
out.OutputPrice = first.Output
|
||||
out.CacheWritePrice = first.CacheWrite
|
||||
out.CacheWrite1hPrice = first.CacheWrite1h
|
||||
out.CacheReadPrice = first.CacheRead
|
||||
if len(sched.Tiers) > 1 {
|
||||
out.Intervals = plazaIntervalsFromTiers(sched.Tiers)
|
||||
@@ -280,14 +281,15 @@ func plazaIntervalsFromTiers(tiers []ContextPricingTier) []PricingInterval {
|
||||
intervals := make([]PricingInterval, 0, len(tiers))
|
||||
for i, t := range tiers {
|
||||
intervals = append(intervals, PricingInterval{
|
||||
MinTokens: t.MinTokens,
|
||||
MaxTokens: t.MaxTokens,
|
||||
TierLabel: t.Label,
|
||||
InputPrice: t.Input,
|
||||
OutputPrice: t.Output,
|
||||
CacheWritePrice: t.CacheWrite,
|
||||
CacheReadPrice: t.CacheRead,
|
||||
SortOrder: i,
|
||||
MinTokens: t.MinTokens,
|
||||
MaxTokens: t.MaxTokens,
|
||||
TierLabel: t.Label,
|
||||
InputPrice: t.Input,
|
||||
OutputPrice: t.Output,
|
||||
CacheWritePrice: t.CacheWrite,
|
||||
CacheWrite1hPrice: t.CacheWrite1h,
|
||||
CacheReadPrice: t.CacheRead,
|
||||
SortOrder: i,
|
||||
})
|
||||
}
|
||||
return intervals
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PricingRow from './PricingRow.vue'
|
||||
import { formatScaled } from '@/utils/pricing'
|
||||
import { formatScaled, resolveIntervalPrices } from '@/utils/pricing'
|
||||
import {
|
||||
BILLING_MODE_TOKEN,
|
||||
BILLING_MODE_PER_REQUEST,
|
||||
@@ -245,22 +245,13 @@ function formatRange(min: number, max: number | null): string {
|
||||
return `(${min}, ${maxLabel}]`
|
||||
}
|
||||
|
||||
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(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)
|
||||
const resolved = resolveIntervalPrices(iv, pricing)
|
||||
const input = formatScaled(resolved.input_price, perMillionScale)
|
||||
const output = formatScaled(resolved.output_price, perMillionScale)
|
||||
return `${input} / ${output}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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(effectiveIntervalPrice(iv.input_price, iv.input_multiplier, m.pricing?.input_price), period) }}
|
||||
{{ paidPerMillion(iv.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(effectiveIntervalPrice(iv.output_price, iv.output_multiplier, m.pricing?.output_price), period) }}
|
||||
{{ paidPerMillion(iv.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 || iv.cache_write_multiplier != null || iv.cache_read_multiplier != null">
|
||||
<template v-if="iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null">
|
||||
<span class="font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
|
||||
{{ 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)
|
||||
{{ 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)
|
||||
}}<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(effectiveIntervalPrice(iv.cache_read_price, iv.cache_read_multiplier, m.pricing?.cache_read_price), period) }}
|
||||
{{ paidPerMillion(iv.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(effectiveIntervalPrice(iv.input_price, iv.input_multiplier, m.official_pricing?.input_price)) }}
|
||||
{{ official(iv.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(effectiveIntervalPrice(iv.output_price, iv.output_multiplier, m.official_pricing?.output_price)) }}
|
||||
{{ official(iv.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 || iv.cache_write_multiplier != null || iv.cache_read_multiplier != null">
|
||||
<template v-if="iv.cache_write_price != null || iv.cache_write_1h_price != null || iv.cache_read_price != null">
|
||||
<span class="font-sans text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWriteShort') }}</span>
|
||||
{{ 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))
|
||||
{{ 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)
|
||||
}}<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(effectiveIntervalPrice(iv.cache_read_price, iv.cache_read_multiplier, m.official_pricing?.cache_read_price)) }}
|
||||
{{ official(iv.cache_read_price) }}
|
||||
</template>
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
</div>
|
||||
@@ -306,7 +306,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatScaled } from '@/utils/pricing'
|
||||
import { formatScaled, resolveIntervalPrices } from '@/utils/pricing'
|
||||
import { platformAccentColor, platformBadgeLightClass, platformLabel } from '@/utils/platformColors'
|
||||
import {
|
||||
BILLING_MODE_TOKEN,
|
||||
@@ -434,16 +434,6 @@ 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 +485,7 @@ function sortByContext(intervals: UserPricingInterval[]): UserPricingInterval[]
|
||||
|
||||
/** token 模式的阶梯定价(内联进输入/输出/缓存列)。 */
|
||||
function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
|
||||
return sortByContext(m.pricing?.intervals ?? [])
|
||||
return sortByContext(m.pricing?.intervals ?? []).map(iv => resolveIntervalPrices(iv, m.pricing!))
|
||||
}
|
||||
|
||||
/** 官方阶梯(后端按目录规则合成,不受分组开关影响)。 */
|
||||
|
||||
@@ -330,6 +330,15 @@ describe('PlazaModelPricingTable', () => {
|
||||
expect(text).toContain('$75.00')
|
||||
expect(text).toContain('$25.00')
|
||||
expect(text).toContain('$4.00')
|
||||
model.pricing!.intervals.unshift({
|
||||
...model.pricing!.intervals[0], min_tokens: 0, max_tokens: 272000,
|
||||
tier_label: '<=272K', cache_write_multiplier: null, cache_read_multiplier: null
|
||||
})
|
||||
const cells = mountTable([model], 1).findAll('tbody td')
|
||||
expect(cells[3].text()).toContain('$12.50')
|
||||
expect(cells[3].text()).toContain('$2.00')
|
||||
expect(cells[3].text()).toContain('$25.00')
|
||||
expect(cells[3].text()).toContain('$4.00')
|
||||
})
|
||||
|
||||
it('生图独立倍率开启时,按图价格 × 独立倍率,不乘分组倍率;倍率列展示独立倍率', () => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveIntervalPrices } from '../pricing'
|
||||
|
||||
const base = { input_price: 10, output_price: 50, cache_write_price: 12.5, cache_write_1h_price: 30, cache_read_price: 2 }
|
||||
const interval = { min_tokens: 272000, max_tokens: null, input_price: null, output_price: null, cache_write_price: null, cache_write_1h_price: null, cache_read_price: null, per_request_price: null }
|
||||
|
||||
describe('resolveIntervalPrices', () => {
|
||||
it('inherits base prices without overrides', () => {
|
||||
expect(resolveIntervalPrices(interval, base)).toMatchObject(base)
|
||||
})
|
||||
it('applies cache multiplier to both durations', () => {
|
||||
expect(resolveIntervalPrices({ ...interval, cache_write_multiplier: 2 }, base)).toMatchObject({ cache_write_price: 25, cache_write_1h_price: 60 })
|
||||
})
|
||||
it('uses explicit cache write for both durations before multiplier', () => {
|
||||
expect(resolveIntervalPrices({ ...interval, cache_write_price: 20, cache_write_multiplier: 2 }, base)).toMatchObject({ cache_write_price: 20, cache_write_1h_price: 20 })
|
||||
})
|
||||
it('preserves explicit zero and separate 1h override', () => {
|
||||
expect(resolveIntervalPrices({ ...interval, cache_write_price: 0, cache_write_1h_price: 7, cache_write_multiplier: 2 }, base)).toMatchObject({ cache_write_price: 0, cache_write_1h_price: 7 })
|
||||
})
|
||||
})
|
||||
@@ -22,3 +22,21 @@ export function formatScaled(value: number | null, scale: number, minFractionDig
|
||||
}
|
||||
return `$${s}`
|
||||
}
|
||||
|
||||
import type { UserPricingInterval } from '@/api/channels'
|
||||
|
||||
type TokenPrices = Pick<UserPricingInterval, 'input_price' | 'output_price' | 'cache_write_price' | 'cache_write_1h_price' | 'cache_read_price'>
|
||||
|
||||
export function resolveIntervalPrices(iv: UserPricingInterval, base: TokenPrices): UserPricingInterval {
|
||||
const price = (absolute: number | null | undefined, multiplier: number | null | undefined, fallback: number | null | undefined) =>
|
||||
absolute ?? (fallback == null ? null : fallback * (multiplier ?? 1))
|
||||
return {
|
||||
...iv,
|
||||
input_price: price(iv.input_price, iv.input_multiplier, base.input_price),
|
||||
output_price: price(iv.output_price, iv.output_multiplier, base.output_price),
|
||||
cache_write_price: price(iv.cache_write_price, iv.cache_write_multiplier, base.cache_write_price),
|
||||
// Resolver uses an explicit cache-write price for both durations unless 1h is overridden.
|
||||
cache_write_1h_price: iv.cache_write_1h_price ?? iv.cache_write_price ?? price(null, iv.cache_write_multiplier, base.cache_write_1h_price),
|
||||
cache_read_price: price(iv.cache_read_price, iv.cache_read_multiplier, base.cache_read_price)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user