前端:配置渠道倍率并精简长上下文开关
This commit is contained in:
1 parent
7dae055f2a
commit
26be82cc85
15 files changed
+276
-35
No files matched your search
@@ -17,6 +17,10 @@ export interface PricingInterval {
|
||||
output_price: number | null
|
||||
cache_write_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
|
||||
sort_order: number
|
||||
}
|
||||
@@ -41,6 +45,8 @@ export interface ChannelModelPricing {
|
||||
output_price: number | null
|
||||
cache_write_price: number | null
|
||||
cache_read_price: number | null
|
||||
fast_multiplier?: number | null
|
||||
flex_multiplier?: number | null
|
||||
image_input_price: number | null
|
||||
image_output_price: number | null
|
||||
per_request_price: number | null
|
||||
|
||||
@@ -2957,7 +2957,7 @@
|
||||
|
||||
<!-- OpenAI WS Mode 三态(off/ctx_pool/passthrough) -->
|
||||
<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">
|
||||
@@ -3740,6 +3740,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 +3858,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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export default {
|
||||
maxPositive: 'Interval #{index}: maximum token count ({value}) must be greater than 0',
|
||||
maxGreaterThanMin: 'Interval #{index}: maximum token count ({max}) must be greater than minimum token count ({min})',
|
||||
negativePrice: 'Interval #{index}: {field} cannot be negative',
|
||||
multiplierPositive: 'Interval #{index}: {field} must be greater than 0',
|
||||
unboundedLast: 'Interval #{index}: an unbounded interval (empty maximum token count) must be last',
|
||||
overlap: 'Intervals #{previousIndex} and #{currentIndex} overlap: previous upper bound ({previousMax}) is greater than current lower bound ({currentMin})',
|
||||
price: {
|
||||
@@ -128,6 +129,14 @@ export default {
|
||||
imageTokenPrice: 'Image Output',
|
||||
imageOutputPrice: 'Image Output Price',
|
||||
pricePlaceholder: 'Default',
|
||||
fastMultiplier: 'Fast Multiplier',
|
||||
flexMultiplier: 'Flex Multiplier',
|
||||
multiplierPlaceholder: 'Not set',
|
||||
multiplierPositive: 'Fast/Flex multipliers must be greater than 0',
|
||||
inputMultiplier: 'Input Mult.',
|
||||
outputMultiplier: 'Output Mult.',
|
||||
cacheWriteMultiplier: 'Cache Write Mult.',
|
||||
cacheReadMultiplier: 'Cache Read Mult.',
|
||||
intervals: 'Context Intervals (optional)',
|
||||
timePricing: 'Time-based pricing (optional)',
|
||||
timezone: 'Time zone',
|
||||
|
||||
@@ -1011,7 +1011,7 @@ export default {
|
||||
title: 'Per-model group pricing',
|
||||
description: 'Overrides channel and built-in prices for matching models. Long-context tiers come from official presets — do not enter custom intervals. Use per-request tiers such as realtime, tts, and stt for audio.',
|
||||
longContext: 'Enable long-context tier pricing',
|
||||
longContextHint: 'When checked, official/preset long-context tiers apply. When unchecked, token models stay on the first-tier base rate.',
|
||||
longContextHint: 'When checked, channel intervals or official preset tiers apply. Otherwise the first tier is used unless the account explicitly enables long-context billing.',
|
||||
add: 'Add model price'
|
||||
},
|
||||
voicePricing: {
|
||||
|
||||
@@ -70,6 +70,7 @@ export default {
|
||||
maxPositive: '区间 #{index}:最大 token 数({value})必须大于 0',
|
||||
maxGreaterThanMin: '区间 #{index}:最大 token 数({max})必须大于最小 token 数({min})',
|
||||
negativePrice: '区间 #{index}:{field}不能为负数',
|
||||
multiplierPositive: '区间 #{index}:{field}必须大于 0',
|
||||
unboundedLast: '区间 #{index}:无上限区间(最大 token 数为空)必须放在最后',
|
||||
overlap: '区间 #{previousIndex} 和 #{currentIndex} 重叠:前一个上界({previousMax})大于当前下界({currentMin})',
|
||||
price: {
|
||||
@@ -128,6 +129,14 @@ export default {
|
||||
imageTokenPrice: '图片输出',
|
||||
imageOutputPrice: '图片输出价格',
|
||||
pricePlaceholder: '默认',
|
||||
fastMultiplier: 'Fast 倍率',
|
||||
flexMultiplier: 'Flex 倍率',
|
||||
multiplierPlaceholder: '未配置',
|
||||
multiplierPositive: 'Fast/Flex 倍率必须大于 0',
|
||||
inputMultiplier: '输入倍率',
|
||||
outputMultiplier: '输出倍率',
|
||||
cacheWriteMultiplier: '缓存写倍率',
|
||||
cacheReadMultiplier: '缓存读倍率',
|
||||
intervals: '上下文区间定价(可选)',
|
||||
timePricing: '时间段定价(可选)',
|
||||
timezone: '时区',
|
||||
|
||||
@@ -1008,7 +1008,7 @@ export default {
|
||||
title: '分组逐模型定价',
|
||||
description: '匹配模型后覆盖渠道和内置价格。长上下文阶梯沿用官方/预设价卡,无需再手填区间。音频可用按次层级配置 realtime、tts、stt。',
|
||||
longContext: '启用长上下文阶梯定价',
|
||||
longContextHint: '勾选后按官方/预设阶梯计费;关闭则始终按第一档基础价。',
|
||||
longContextHint: '勾选后按渠道区间或官方预设阶梯计费;关闭后默认按第一档,账号显式开启时除外。',
|
||||
add: '添加模型价格'
|
||||
},
|
||||
voicePricing: {
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
:entry="entry"
|
||||
:platform="section.platform"
|
||||
enable-time-pricing
|
||||
enable-tier-multipliers
|
||||
@update="updatePricingEntry(sIdx, idx, $event)"
|
||||
@remove="removePricingEntry(sIdx, idx)"
|
||||
/>
|
||||
@@ -633,7 +634,7 @@ import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest, AccountStatsPricingRule } from '@/api/admin/channels'
|
||||
import type { PricingFormEntry } from '@/components/admin/channel/types'
|
||||
import { apiIntervalsToForm, apiTimePricingToForm, createDefaultTimePricingForm, findModelConflict, formIntervalsToAPI, formTimePricingToAPI, mTokToPerToken, perTokenToMTok, validateIntervals, validateTimePricing } from '@/components/admin/channel/types'
|
||||
import { apiIntervalsToForm, apiTimePricingToForm, createDefaultTimePricingForm, findModelConflict, formIntervalsToAPI, formTimePricingToAPI, isValidPositiveMultiplier, mTokToPerToken, perTokenToMTok, validateIntervals, validateTimePricing } from '@/components/admin/channel/types'
|
||||
import type { AdminGroup, GroupPlatform } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { platformTextClass, platformBadgeLightClass } from '@/utils/platformColors'
|
||||
@@ -861,6 +862,8 @@ function addPricingEntry(sectionIdx: number) {
|
||||
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,
|
||||
@@ -895,6 +898,8 @@ async function syncLatestModels(sectionIdx: number) {
|
||||
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,
|
||||
@@ -1120,6 +1125,8 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
|
||||
output_price: mTokToPerToken(entry.output_price),
|
||||
cache_write_price: mTokToPerToken(entry.cache_write_price),
|
||||
cache_read_price: mTokToPerToken(entry.cache_read_price),
|
||||
fast_multiplier: entry.fast_multiplier != null && entry.fast_multiplier !== '' ? Number(entry.fast_multiplier) : null,
|
||||
flex_multiplier: entry.flex_multiplier != null && entry.flex_multiplier !== '' ? Number(entry.flex_multiplier) : null,
|
||||
image_input_price: mTokToPerToken(entry.image_input_price),
|
||||
image_output_price: mTokToPerToken(entry.image_output_price),
|
||||
per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null,
|
||||
@@ -1219,6 +1226,8 @@ function apiToForm(channel: Channel): PlatformSection[] {
|
||||
output_price: perTokenToMTok(p.output_price),
|
||||
cache_write_price: perTokenToMTok(p.cache_write_price),
|
||||
cache_read_price: perTokenToMTok(p.cache_read_price),
|
||||
fast_multiplier: p.fast_multiplier,
|
||||
flex_multiplier: p.flex_multiplier,
|
||||
image_input_price: perTokenToMTok(p.image_input_price),
|
||||
image_output_price: perTokenToMTok(p.image_output_price),
|
||||
per_request_price: p.per_request_price,
|
||||
@@ -1524,6 +1533,14 @@ async function handleSubmit() {
|
||||
// 校验区间合法性(范围、重叠等)
|
||||
for (const section of form.platforms.filter(s => s.enabled)) {
|
||||
for (const entry of section.model_pricing) {
|
||||
if (!isValidPositiveMultiplier(entry.fast_multiplier) ||
|
||||
!isValidPositiveMultiplier(entry.flex_multiplier)) {
|
||||
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
|
||||
const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed')
|
||||
appStore.showError(`${platformLabel} - ${modelLabel}: ${t('admin.channels.form.multiplierPositive')}`)
|
||||
activeTab.value = section.platform
|
||||
return
|
||||
}
|
||||
if (!entry.intervals || entry.intervals.length === 0) continue
|
||||
const intervalErr = validateIntervals(entry.intervals, entry.billing_mode, t)
|
||||
if (intervalErr) {
|
||||
|
||||
Reference in new issue
Block a user