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:
Wesley Liddick
2026-08-20 10:13:17 +08:00
committed by GitHub
39 changed files with 1303 additions and 280 deletions
@@ -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
}