mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
Merge pull request #5851 from IanShaw027/feat/channel-pricing-tier-multipliers
fix(billing): restore Fast tier pricing under channel overrides, add configurable multipliers
This commit is contained in:
@@ -2958,6 +2958,7 @@
|
||||
<!-- OpenAI WS Mode 三态(off/ctx_pool/passthrough) -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
|
||||
data-testid="create-openai-ws-mode"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -3044,9 +3045,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
|
||||
<!-- OpenAI API 长上下文计费开关 -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
|
||||
v-if="form.platform === 'openai' && !hideAccountLongContextBilling && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
@@ -3740,6 +3741,7 @@ import Toggle from '@/components/common/Toggle.vue'
|
||||
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
|
||||
import CnBaseUrlPresets from '@/components/account/CnBaseUrlPresets.vue'
|
||||
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
|
||||
import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling'
|
||||
import {
|
||||
applyAntigravityProjectID,
|
||||
applyHeaderOverride,
|
||||
@@ -3857,6 +3859,10 @@ const emit = defineEmits<{
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const hideAccountLongContextBilling = computed(() => {
|
||||
return allSelectedGroupsEnableLongContextPricing(form.group_ids, props.groups)
|
||||
})
|
||||
|
||||
// OAuth composables
|
||||
const oauth = useAccountOAuth() // For Anthropic OAuth
|
||||
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth
|
||||
|
||||
@@ -1966,7 +1966,7 @@
|
||||
|
||||
<!-- OpenAI API 长上下文计费开关 -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && !isSparkShadow && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
|
||||
v-if="account?.platform === 'openai' && !isSparkShadow && !hideAccountLongContextBilling && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
@@ -2811,6 +2811,7 @@ import {
|
||||
} from '@/components/account/credentialsBuilder'
|
||||
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling'
|
||||
import { VERTEX_LOCATION_OPTIONS } from '@/constants/account'
|
||||
import {
|
||||
OPENAI_WS_MODE_CTX_POOL,
|
||||
@@ -2851,6 +2852,10 @@ const authStore = useAuthStore()
|
||||
// 故隐藏代理选择器。
|
||||
const isSparkShadow = computed(() => props.account?.parent_account_id != null)
|
||||
|
||||
const hideAccountLongContextBilling = computed(() => {
|
||||
return allSelectedGroupsEnableLongContextPricing(form.group_ids, props.groups)
|
||||
})
|
||||
|
||||
const handleOllamaCloudUsageUpdated = (state: OllamaCloudUsageState) => {
|
||||
if (props.account) emit('updated', { ...props.account, ollama_cloud_usage: state })
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ const {
|
||||
probeUpstreamBillingMock,
|
||||
importCodexSessionMock,
|
||||
createOpenAICodexPATMock,
|
||||
authIsSimpleMode,
|
||||
} = vi.hoisted(() => ({
|
||||
createAccountMock: vi.fn(),
|
||||
probeUpstreamBillingMock: vi.fn(),
|
||||
importCodexSessionMock: vi.fn(),
|
||||
createOpenAICodexPATMock: vi.fn(),
|
||||
authIsSimpleMode: { value: true },
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
@@ -23,7 +25,11 @@ vi.mock('@/stores/app', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({ isSimpleMode: true }),
|
||||
useAuthStore: () => ({
|
||||
get isSimpleMode() {
|
||||
return authIsSimpleMode.value
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
@@ -84,9 +90,29 @@ const OAuthAuthorizationFlowStub = defineComponent({
|
||||
`,
|
||||
})
|
||||
|
||||
function mountModal() {
|
||||
const GroupSelectorStub = defineComponent({
|
||||
name: 'GroupSelector',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-pricing-groups"
|
||||
@click="$emit('update:modelValue', [1, 2])"
|
||||
>
|
||||
groups
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
|
||||
function mountModal(groups: any[] = []) {
|
||||
return mount(CreateAccountModal, {
|
||||
props: { show: true, proxies: [], groups: [] },
|
||||
props: { show: true, proxies: [], groups },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: BaseDialogStub,
|
||||
@@ -97,7 +123,7 @@ function mountModal() {
|
||||
PlatformIcon: true,
|
||||
ProxySelector: true,
|
||||
ProxyAdBanner: true,
|
||||
GroupSelector: true,
|
||||
GroupSelector: GroupSelectorStub,
|
||||
ModelWhitelistSelector: true,
|
||||
QuotaLimitCard: true,
|
||||
},
|
||||
@@ -147,6 +173,7 @@ async function openCodexImportStep(toggleClicks = 0) {
|
||||
|
||||
describe('CreateAccountModal OpenAI long-context billing', () => {
|
||||
beforeEach(() => {
|
||||
authIsSimpleMode.value = true
|
||||
createAccountMock.mockReset().mockResolvedValue({ id: 42, platform: 'openai', type: 'apikey' })
|
||||
probeUpstreamBillingMock.mockReset().mockResolvedValue({})
|
||||
importCodexSessionMock.mockReset().mockResolvedValue({
|
||||
@@ -160,6 +187,34 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
|
||||
createOpenAICodexPATMock.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('hides only the redundant account toggle when every selected group enables tier pricing', async () => {
|
||||
authIsSimpleMode.value = false
|
||||
const wrapper = mountModal([
|
||||
{ id: 1, long_context_pricing_enabled: true },
|
||||
{ id: 2, long_context_pricing_enabled: true },
|
||||
])
|
||||
|
||||
await selectButtonByText(wrapper, 'OpenAI')
|
||||
await wrapper.get('[data-testid="select-pricing-groups"]').trigger('click')
|
||||
|
||||
expect(wrapper.find('[data-testid="openai-long-context-billing-toggle"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="create-openai-ws-mode"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the account toggle when any selected group disables tier pricing', async () => {
|
||||
authIsSimpleMode.value = false
|
||||
const wrapper = mountModal([
|
||||
{ id: 1, long_context_pricing_enabled: true },
|
||||
{ id: 2, long_context_pricing_enabled: false },
|
||||
])
|
||||
|
||||
await selectButtonByText(wrapper, 'OpenAI')
|
||||
await wrapper.get('[data-testid="select-pricing-groups"]').trigger('click')
|
||||
|
||||
expect(wrapper.find('[data-testid="openai-long-context-billing-toggle"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="create-openai-ws-mode"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('sends false explicitly for normal OpenAI account creation by default', async () => {
|
||||
await submitApiKeyAccount('openai')
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { allSelectedGroupsEnableLongContextPricing } from '../longContextBilling'
|
||||
|
||||
const group = (id: number, enabled: boolean) => ({
|
||||
id,
|
||||
long_context_pricing_enabled: enabled,
|
||||
}) as any
|
||||
|
||||
describe('allSelectedGroupsEnableLongContextPricing', () => {
|
||||
it('hides the account override when every selected group enables tier pricing', () => {
|
||||
expect(allSelectedGroupsEnableLongContextPricing(
|
||||
[1, 2],
|
||||
[group(1, true), group(2, true)]
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the account override when any selected group disables tier pricing', () => {
|
||||
expect(allSelectedGroupsEnableLongContextPricing(
|
||||
[1, 2],
|
||||
[group(1, true), group(2, false)]
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the account override when selection is empty or group data is incomplete', () => {
|
||||
expect(allSelectedGroupsEnableLongContextPricing([], [group(1, true)])).toBe(false)
|
||||
expect(allSelectedGroupsEnableLongContextPricing([1, 2], [group(1, true)])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AdminGroup } from '@/types'
|
||||
|
||||
export function allSelectedGroupsEnableLongContextPricing(
|
||||
groupIds: number[],
|
||||
groups: AdminGroup[]
|
||||
): boolean {
|
||||
if (groupIds.length === 0) return false
|
||||
const selectedGroups = groups.filter(group => groupIds.includes(group.id))
|
||||
return selectedGroups.length === groupIds.length &&
|
||||
selectedGroups.every(group => group.long_context_pricing_enabled === true)
|
||||
}
|
||||
@@ -3,35 +3,59 @@
|
||||
:class="isEmpty ? 'border-red-400 bg-red-50 dark:border-red-500 dark:bg-red-950/20' : 'border-gray-200 bg-white dark:border-dark-500 dark:bg-dark-700'">
|
||||
<!-- Token mode: context range + prices ($/MTok) -->
|
||||
<template v-if="mode === 'token'">
|
||||
<div class="w-20">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens') }}</label>
|
||||
<input :value="interval.min_tokens" @input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
|
||||
type="number" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div class="w-20">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.maxTokens') }} <span class="text-gray-300">{{ t('admin.channels.form.inclusive') }}</span></label>
|
||||
<input :value="interval.max_tokens ?? ''" @input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
|
||||
type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePriceShort') }} <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPriceShort') }} <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
<div class="grid min-w-0 flex-1 grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-6">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens') }}</label>
|
||||
<input :value="interval.min_tokens" @input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
|
||||
type="number" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.maxTokens') }} <span class="text-gray-300">{{ t('admin.channels.form.inclusive') }}</span></label>
|
||||
<input :value="interval.max_tokens ?? ''" @input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
|
||||
type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePriceShort') }} <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPriceShort') }} <span class="text-gray-300">$/M</span></label>
|
||||
<input :value="interval.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<template v-if="enableMultipliers">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputMultiplier') }}</label>
|
||||
<input :value="interval.input_multiplier" @input="emitField('input_multiplier', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0.000001" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputMultiplier') }}</label>
|
||||
<input :value="interval.output_multiplier" @input="emitField('output_multiplier', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0.000001" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWriteMultiplier') }}</label>
|
||||
<input :value="interval.cache_write_multiplier" @input="emitField('cache_write_multiplier', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0.000001" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadMultiplier') }}</label>
|
||||
<input :value="interval.cache_read_multiplier" @input="emitField('cache_read_multiplier', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0.000001" class="input mt-0.5 text-xs" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -79,6 +103,7 @@ const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
interval: IntervalFormEntry
|
||||
mode: BillingMode
|
||||
enableMultipliers?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -93,6 +118,10 @@ const isEmpty = computed(() => {
|
||||
(iv.output_price == null || iv.output_price === '') &&
|
||||
(iv.cache_write_price == null || iv.cache_write_price === '') &&
|
||||
(iv.cache_read_price == null || iv.cache_read_price === '') &&
|
||||
(iv.input_multiplier == null || iv.input_multiplier === '') &&
|
||||
(iv.output_multiplier == null || iv.output_multiplier === '') &&
|
||||
(iv.cache_write_multiplier == null || iv.cache_write_multiplier === '') &&
|
||||
(iv.cache_read_multiplier == null || iv.cache_read_multiplier === '') &&
|
||||
(iv.per_request_price == null || iv.per_request_price === '')
|
||||
})
|
||||
|
||||
|
||||
@@ -139,7 +139,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token intervals (channel-only; group long-context uses official presets) -->
|
||||
<div v-if="enableTierMultipliers" class="mt-3 grid max-w-md grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.fastMultiplier') }}</label>
|
||||
<input :value="entry.fast_multiplier" @input="emitField('fast_multiplier', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0.000001" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.multiplierPlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400">{{ t('admin.channels.form.flexMultiplier') }}</label>
|
||||
<input :value="entry.flex_multiplier" @input="emitField('flex_multiplier', ($event.target as HTMLInputElement).value)"
|
||||
type="number" step="any" min="0.000001" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.multiplierPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel token intervals; the group long-context toggle controls whether tiers apply. -->
|
||||
<div v-if="!hideTokenIntervals" class="mt-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
@@ -156,6 +169,7 @@
|
||||
:key="idx"
|
||||
:interval="iv"
|
||||
:mode="entry.billing_mode"
|
||||
:enable-multipliers="enableTierMultipliers"
|
||||
@update="updateInterval(idx, $event)"
|
||||
@remove="removeInterval(idx)"
|
||||
/>
|
||||
@@ -262,9 +276,11 @@ const props = withDefaults(defineProps<{
|
||||
platform?: string
|
||||
hideTokenIntervals?: boolean
|
||||
enableTimePricing?: boolean
|
||||
enableTierMultipliers?: boolean
|
||||
}>(), {
|
||||
hideTokenIntervals: false,
|
||||
enableTimePricing: false,
|
||||
enableTierMultipliers: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -297,6 +313,8 @@ function addInterval() {
|
||||
min_tokens: 0, max_tokens: null, tier_label: '',
|
||||
input_price: null, output_price: null, cache_write_price: null,
|
||||
cache_read_price: null, per_request_price: null,
|
||||
input_multiplier: null, output_multiplier: null,
|
||||
cache_write_multiplier: null, cache_read_multiplier: null,
|
||||
sort_order: intervals.length
|
||||
})
|
||||
emit('update', { ...props.entry, intervals })
|
||||
@@ -311,6 +329,8 @@ function addMediaTier() {
|
||||
min_tokens: 0, max_tokens: null, tier_label: labels[intervals.length] || '',
|
||||
input_price: null, output_price: null, cache_write_price: null,
|
||||
cache_read_price: null, per_request_price: null,
|
||||
input_multiplier: null, output_multiplier: null,
|
||||
cache_write_multiplier: null, cache_read_multiplier: null,
|
||||
sort_order: intervals.length
|
||||
})
|
||||
emit('update', { ...props.entry, intervals })
|
||||
|
||||
@@ -16,6 +16,8 @@ function createEntry(billingMode: PricingFormEntry['billing_mode'] = 'token'): P
|
||||
output_price: null,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
fast_multiplier: null,
|
||||
flex_multiplier: null,
|
||||
image_input_price: null,
|
||||
image_output_price: null,
|
||||
per_request_price: null,
|
||||
@@ -69,3 +71,16 @@ describe('PricingEntryCard time pricing visibility', () => {
|
||||
expect(entry.time_pricing.periods).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PricingEntryCard service tier multipliers', () => {
|
||||
it('shows Fast and Flex controls only when explicitly enabled', () => {
|
||||
const hidden = shallowMount(PricingEntryCard, { props: { entry: createEntry() } })
|
||||
expect(hidden.text()).not.toContain('admin.channels.form.fastMultiplier')
|
||||
|
||||
const shown = shallowMount(PricingEntryCard, {
|
||||
props: { entry: createEntry(), enableTierMultipliers: true },
|
||||
})
|
||||
expect(shown.text()).toContain('admin.channels.form.fastMultiplier')
|
||||
expect(shown.text()).toContain('admin.channels.form.flexMultiplier')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
apiIntervalsToForm,
|
||||
apiTimePricingToForm,
|
||||
createDefaultTimePricingForm,
|
||||
formIntervalsToAPI,
|
||||
formTimePricingToAPI,
|
||||
isValidPositiveMultiplier,
|
||||
validateIntervals,
|
||||
validateTimePricing,
|
||||
type IntervalFormEntry,
|
||||
@@ -10,6 +13,51 @@ import {
|
||||
type TimePricingPeriodFormEntry,
|
||||
} from '../types'
|
||||
|
||||
describe('interval multiplier conversion', () => {
|
||||
it('preserves component multipliers without MTok conversion', () => {
|
||||
const form = apiIntervalsToForm([{
|
||||
min_tokens: 272000,
|
||||
max_tokens: null,
|
||||
tier_label: '',
|
||||
input_price: null,
|
||||
output_price: null,
|
||||
cache_write_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,
|
||||
sort_order: 0,
|
||||
}])
|
||||
|
||||
expect(form[0].input_multiplier).toBe(2)
|
||||
expect(form[0].output_multiplier).toBe(1.5)
|
||||
expect(formIntervalsToAPI(form)[0]).toMatchObject({
|
||||
input_multiplier: 2,
|
||||
output_multiplier: 1.5,
|
||||
cache_write_multiplier: 2,
|
||||
cache_read_multiplier: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('positive multiplier validation', () => {
|
||||
it('accepts empty and positive values but rejects zero and negative values', () => {
|
||||
expect(isValidPositiveMultiplier(null)).toBe(true)
|
||||
expect(isValidPositiveMultiplier('')).toBe(true)
|
||||
expect(isValidPositiveMultiplier('0.5')).toBe(true)
|
||||
expect(isValidPositiveMultiplier(0)).toBe(false)
|
||||
expect(isValidPositiveMultiplier(-1)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a zero interval multiplier', () => {
|
||||
expect(validateIntervals([
|
||||
makeInterval({ min_tokens: 100, input_multiplier: 0 }),
|
||||
], 'token', t)).toContain('multiplierPositive')
|
||||
})
|
||||
})
|
||||
|
||||
function makeInterval(over: Partial<IntervalFormEntry>): IntervalFormEntry {
|
||||
return {
|
||||
min_tokens: 0,
|
||||
@@ -19,6 +67,10 @@ function makeInterval(over: Partial<IntervalFormEntry>): IntervalFormEntry {
|
||||
output_price: null,
|
||||
cache_write_price: null,
|
||||
cache_read_price: null,
|
||||
input_multiplier: null,
|
||||
output_multiplier: null,
|
||||
cache_write_multiplier: null,
|
||||
cache_read_multiplier: null,
|
||||
per_request_price: null,
|
||||
sort_order: 0,
|
||||
...over,
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface IntervalFormEntry {
|
||||
output_price: number | string | null
|
||||
cache_write_price: number | string | null
|
||||
cache_read_price: number | string | null
|
||||
input_multiplier: number | string | null
|
||||
output_multiplier: number | string | null
|
||||
cache_write_multiplier: number | string | null
|
||||
cache_read_multiplier: number | string | null
|
||||
per_request_price: number | string | null
|
||||
sort_order: number
|
||||
}
|
||||
@@ -21,6 +25,8 @@ export interface PricingFormEntry {
|
||||
output_price: number | string | null
|
||||
cache_write_price: number | string | null
|
||||
cache_read_price: number | string | null
|
||||
fast_multiplier?: number | string | null
|
||||
flex_multiplier?: number | string | null
|
||||
image_input_price: number | string | null
|
||||
image_output_price: number | string | null
|
||||
per_request_price: number | string | null
|
||||
@@ -164,6 +170,12 @@ export function toNullableNumber(val: number | string | null | undefined): numbe
|
||||
return isNaN(num) ? null : num
|
||||
}
|
||||
|
||||
export function isValidPositiveMultiplier(val: number | string | null | undefined): boolean {
|
||||
if (val === null || val === undefined || val === '') return true
|
||||
const multiplier = Number(val)
|
||||
return Number.isFinite(multiplier) && multiplier > 0
|
||||
}
|
||||
|
||||
/** 前端显示值($/MTok) → 后端存储值(per-token) */
|
||||
export function mTokToPerToken(val: number | string | null | undefined): number | null {
|
||||
const num = toNullableNumber(val)
|
||||
@@ -186,6 +198,10 @@ export function apiIntervalsToForm(intervals: PricingInterval[]): IntervalFormEn
|
||||
output_price: perTokenToMTok(iv.output_price),
|
||||
cache_write_price: perTokenToMTok(iv.cache_write_price),
|
||||
cache_read_price: perTokenToMTok(iv.cache_read_price),
|
||||
input_multiplier: iv.input_multiplier,
|
||||
output_multiplier: iv.output_multiplier,
|
||||
cache_write_multiplier: iv.cache_write_multiplier,
|
||||
cache_read_multiplier: iv.cache_read_multiplier,
|
||||
per_request_price: iv.per_request_price,
|
||||
sort_order: iv.sort_order
|
||||
}))
|
||||
@@ -200,6 +216,10 @@ export function formIntervalsToAPI(intervals: IntervalFormEntry[]): PricingInter
|
||||
output_price: mTokToPerToken(iv.output_price),
|
||||
cache_write_price: mTokToPerToken(iv.cache_write_price),
|
||||
cache_read_price: mTokToPerToken(iv.cache_read_price),
|
||||
input_multiplier: toNullableNumber(iv.input_multiplier),
|
||||
output_multiplier: toNullableNumber(iv.output_multiplier),
|
||||
cache_write_multiplier: toNullableNumber(iv.cache_write_multiplier),
|
||||
cache_read_multiplier: toNullableNumber(iv.cache_read_multiplier),
|
||||
per_request_price: toNullableNumber(iv.per_request_price),
|
||||
sort_order: iv.sort_order
|
||||
}))
|
||||
@@ -332,6 +352,20 @@ function validateIntervalPrices(iv: IntervalFormEntry, idx: number, t: Translate
|
||||
)
|
||||
}
|
||||
}
|
||||
const multipliers: [string, number | string | null][] = [
|
||||
['inputMultiplier', iv.input_multiplier],
|
||||
['outputMultiplier', iv.output_multiplier],
|
||||
['cacheWriteMultiplier', iv.cache_write_multiplier],
|
||||
['cacheReadMultiplier', iv.cache_read_multiplier],
|
||||
]
|
||||
for (const [key, val] of multipliers) {
|
||||
if (!isValidPositiveMultiplier(val)) {
|
||||
return intervalValidationMessage(t, 'multiplierPositive', {
|
||||
index,
|
||||
field: intervalPriceLabel(t, key),
|
||||
})
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user