feat: 分组支持逐模型定价,并可关闭长上下文阶梯

运营需要按分组覆盖渠道/内置价,且部分套餐不应自动吃 200k 倍率。
原先只能改渠道价卡,分组侧只剩 Voice 三列。

- groups 新增 model_pricing / long_context_pricing_enabled,解析链改为 Group → Channel → 内置
- 关闭长上下文时 token 模型只取最低档;video 按秒计费可写进同一价卡
- 回退价对齐官方卡:4.5 缓存 $0.30、4.3/imagine/audio/search 默认值一并校正
This commit is contained in:
IanShaw027
2026-08-13 07:49:08 +08:00
parent 69648476d4
commit f3d9491071
35 changed files with 1094 additions and 176 deletions
@@ -194,11 +194,11 @@
</div>
</div>
<!-- Image mode -->
<div v-else-if="entry.billing_mode === 'image'">
<!-- Image/video mode -->
<div v-else-if="entry.billing_mode === 'image' || entry.billing_mode === 'video'">
<!-- Default image price (per-request, same as per_request mode) -->
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.defaultImagePrice') }}
{{ entry.billing_mode === 'video' ? t('admin.channels.form.defaultVideoPrice') : t('admin.channels.form.defaultImagePrice') }}
<span class="ml-1 font-normal text-gray-400">$</span>
</label>
<div class="mt-1 w-48">
@@ -209,9 +209,9 @@
<!-- Image tiers -->
<div class="mt-3 flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.imageTiers') }}
{{ entry.billing_mode === 'video' ? t('admin.channels.form.videoTiers') : t('admin.channels.form.imageTiers') }}
</label>
<button type="button" @click="addImageTier" class="text-xs text-primary-600 hover:text-primary-700">
<button type="button" @click="addMediaTier" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addTier') }}
</button>
</div>
@@ -261,7 +261,8 @@ const collapsed = ref(props.entry.models.length > 0)
const billingModeOptions = computed(() => [
{ value: 'token', label: t('admin.channels.billingMode.token') },
{ value: 'per_request', label: t('admin.channels.billingMode.perRequest') },
{ value: 'image', label: t('admin.channels.billingMode.image') }
{ value: 'image', label: t('admin.channels.billingMode.image') },
{ value: 'video', label: t('admin.channels.billingMode.video') }
])
const billingModeLabel = computed(() => {
@@ -284,9 +285,11 @@ function addInterval() {
emit('update', { ...props.entry, intervals })
}
function addImageTier() {
function addMediaTier() {
const intervals = [...(props.entry.intervals || [])]
const labels = ['1K', '2K', '4K', 'HD']
const labels = props.entry.billing_mode === 'video'
? ['480p', '720p', '1080p']
: ['1K', '2K', '4K', 'HD']
intervals.push({
min_tokens: 0, max_tokens: null, tier_label: labels[intervals.length] || '',
input_price: null, output_price: null, cache_write_price: null,
+2
View File
@@ -7,10 +7,12 @@ export type ChannelStatus = typeof CHANNEL_STATUS_ACTIVE | typeof CHANNEL_STATUS
export const BILLING_MODE_TOKEN = 'token' as const
export const BILLING_MODE_PER_REQUEST = 'per_request' as const
export const BILLING_MODE_IMAGE = 'image' as const
export const BILLING_MODE_VIDEO = 'video' as const
export type BillingMode =
| typeof BILLING_MODE_TOKEN
| typeof BILLING_MODE_PER_REQUEST
| typeof BILLING_MODE_IMAGE
| typeof BILLING_MODE_VIDEO
/** Billing-model-source values (must match service.BillingModelSource* constants in Go). */
export const BILLING_MODEL_SOURCE_REQUESTED = 'requested' as const
@@ -93,7 +93,8 @@ export default {
billingMode: {
token: 'Token',
perRequest: 'Per Request',
image: 'Image (Per Request)'
image: 'Image (Per Request)',
video: 'Video (Per Second)'
},
form: {
name: 'Name',
@@ -127,6 +128,7 @@ export default {
addInterval: 'Add Interval',
requestTiers: 'Request Tiers',
imageTiers: 'Image Tiers (Per Request)',
videoTiers: 'Video Resolution Tiers (Per Second)',
addTier: 'Add Tier',
noTiersYet: 'No tiers yet. Click add to configure per-request pricing.',
noPricingRules: 'No pricing rules yet. Click "Add" to create one.',
@@ -152,6 +154,7 @@ export default {
restrictModelsHint: 'When enabled, only models in the pricing list are allowed. Others will be rejected.',
defaultPerRequestPrice: 'Default per-request price (fallback when no tier matches)',
defaultImagePrice: 'Default image price (fallback when no tier matches)',
defaultVideoPrice: 'Default video price per second (fallback when no tier matches)',
platformConfig: 'Platform Configuration',
webSearchEmulation: 'Web Search Emulation',
webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.',
@@ -1005,6 +1005,13 @@ export default {
searchPricePer1k: 'Search price per 1k calls (USD)',
pricePlaceholder: 'optional'
},
modelPricing: {
title: 'Per-model group pricing',
description: 'Overrides channel and built-in prices for matching models. Use per-request tiers such as realtime, tts, and stt for audio.',
longContext: 'Enable long-context tier pricing',
longContextHint: 'When disabled, token models always use the lowest configured tier.',
add: 'Add model price'
},
voicePricing: {
title: 'Grok Voice Pricing',
description: 'Optional per-group prices for Voice realtime / TTS / STT (USD). Leave empty to leave unpriced.',
@@ -93,7 +93,8 @@ export default {
billingMode: {
token: 'Token',
perRequest: '按次',
image: '图片(按次)'
image: '图片(按次)',
video: '视频(按秒)'
},
form: {
name: '名称',
@@ -127,6 +128,7 @@ export default {
addInterval: '添加区间',
requestTiers: '按次计费层级',
imageTiers: '图片计费层级(按次)',
videoTiers: '视频分辨率层级(按秒)',
addTier: '添加层级',
noTiersYet: '暂无层级,点击添加配置按次计费价格',
noPricingRules: '暂无定价规则,点击"添加"创建',
@@ -152,6 +154,7 @@ export default {
restrictModelsHint: '开启后,仅允许模型定价列表中的模型。不在列表中的模型请求将被拒绝。',
defaultPerRequestPrice: '默认单次价格(未命中层级时使用)',
defaultImagePrice: '默认图片价格(未命中层级时使用)',
defaultVideoPrice: '默认视频每秒价格(未命中层级时使用)',
platformConfig: '平台配置',
webSearchEmulation: 'Web Search 模拟',
webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作',
@@ -1002,6 +1002,13 @@ export default {
searchPricePer1k: '搜索每千次价格(USD)',
pricePlaceholder: '可选'
},
modelPricing: {
title: '分组逐模型定价',
description: '匹配模型后覆盖渠道和内置价格。音频可用按次层级配置 realtime、tts、stt。',
longContext: '启用长上下文阶梯定价',
longContextHint: '关闭时,Token 模型始终只使用配置的最低档。',
add: '添加模型价格'
},
voicePricing: {
title: 'Grok Voice 定价',
description: '分组级 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。',
+6
View File
@@ -558,6 +558,7 @@ export interface Group {
daily_limit_usd: number | null
weekly_limit_usd: number | null
monthly_limit_usd: number | null
long_context_pricing_enabled: boolean
// 图片生成计费配置
allow_image_generation: boolean
allow_batch_image_generation: boolean
@@ -604,6 +605,7 @@ export interface Group {
}
export interface AdminGroup extends Group {
model_pricing: import('@/api/admin/channels').ChannelModelPricing[]
// 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。
// 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。
profit_control_enabled: boolean
@@ -766,6 +768,8 @@ export interface CreateGroupRequest {
daily_limit_usd?: number | null
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
long_context_pricing_enabled?: boolean
model_pricing?: import('@/api/admin/channels').ChannelModelPricing[]
allow_image_generation?: boolean
allow_batch_image_generation?: boolean
image_rate_independent?: boolean
@@ -826,6 +830,8 @@ export interface UpdateGroupRequest {
daily_limit_usd?: number | null
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
long_context_pricing_enabled?: boolean
model_pricing?: import('@/api/admin/channels').ChannelModelPricing[]
allow_image_generation?: boolean
allow_batch_image_generation?: boolean
image_rate_independent?: boolean
+119
View File
@@ -1484,6 +1484,25 @@
</div>
<div class="border-t border-gray-200 pt-4 mt-4 dark:border-dark-400">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.title") }}</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t("admin.groups.modelPricing.description") }}</p>
</div>
<button type="button" class="btn btn-secondary" @click="addGroupPricing(createForm.model_pricing)">
<Icon name="plus" size="sm" class="mr-1" />{{ t("admin.groups.modelPricing.add") }}
</button>
</div>
<label class="mt-3 flex items-start gap-2">
<input v-model="createForm.long_context_pricing_enabled" type="checkbox" class="mt-0.5" />
<span><span class="block text-sm text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.longContext") }}</span><span class="block text-xs text-gray-500">{{ t("admin.groups.modelPricing.longContextHint") }}</span></span>
</label>
<div class="mt-3 space-y-2">
<PricingEntryCard v-for="(entry, index) in createForm.model_pricing" :key="index" :entry="entry" :platform="createForm.platform" @update="createForm.model_pricing[index] = $event" @remove="createForm.model_pricing.splice(index, 1)" />
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="createForm.platform === 'grok'"
@@ -3187,6 +3206,25 @@
</div>
<div class="border-t border-gray-200 pt-4 mt-4 dark:border-dark-400">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.title") }}</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t("admin.groups.modelPricing.description") }}</p>
</div>
<button type="button" class="btn btn-secondary" @click="addGroupPricing(editForm.model_pricing)">
<Icon name="plus" size="sm" class="mr-1" />{{ t("admin.groups.modelPricing.add") }}
</button>
</div>
<label class="mt-3 flex items-start gap-2">
<input v-model="editForm.long_context_pricing_enabled" type="checkbox" class="mt-0.5" />
<span><span class="block text-sm text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.longContext") }}</span><span class="block text-xs text-gray-500">{{ t("admin.groups.modelPricing.longContextHint") }}</span></span>
</label>
<div class="mt-3 space-y-2">
<PricingEntryCard v-for="(entry, index) in editForm.model_pricing" :key="index" :entry="entry" :platform="editForm.platform" @update="editForm.model_pricing[index] = $event" @remove="editForm.model_pricing.splice(index, 1)" />
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="editForm.platform === 'grok'"
@@ -4377,6 +4415,16 @@ import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipl
import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesModal.vue";
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
import ReasoningEffortPolicyFields from "@/components/admin/group/ReasoningEffortPolicyFields.vue";
import PricingEntryCard from "@/components/admin/channel/PricingEntryCard.vue";
import type { PricingFormEntry } from "@/components/admin/channel/types";
import {
apiIntervalsToForm,
formIntervalsToAPI,
mTokToPerToken,
perTokenToMTok,
toNullableNumber,
} from "@/components/admin/channel/types";
import type { ChannelModelPricing } from "@/api/admin/channels";
import { VueDraggable } from "vue-draggable-plus";
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
import { extractApiErrorMessage } from "@/utils/apiError";
@@ -4430,6 +4478,58 @@ import {
videoModelPriceFamilyRows,
} from "./groupsVideoModelPricing";
const emptyGroupPricing = (): PricingFormEntry => ({
models: [],
billing_mode: "token",
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: [],
});
const addGroupPricing = (entries: PricingFormEntry[]) =>
entries.push(emptyGroupPricing());
const groupPricingFromAPI = (
pricing: ChannelModelPricing[] | undefined,
): PricingFormEntry[] =>
(pricing || []).map((entry) => ({
models: entry.models || [],
billing_mode: entry.billing_mode || "token",
input_price: perTokenToMTok(entry.input_price),
output_price: perTokenToMTok(entry.output_price),
cache_write_price: perTokenToMTok(entry.cache_write_price),
cache_read_price: perTokenToMTok(entry.cache_read_price),
image_input_price: perTokenToMTok(entry.image_input_price),
image_output_price: perTokenToMTok(entry.image_output_price),
per_request_price: entry.per_request_price,
intervals: apiIntervalsToForm(entry.intervals || []),
}));
const groupPricingToAPI = (
pricing: PricingFormEntry[],
platform: string,
): ChannelModelPricing[] =>
pricing
.filter((entry) => entry.models.length > 0)
.map((entry) => ({
platform,
models: entry.models,
billing_mode: entry.billing_mode,
input_price: mTokToPerToken(entry.input_price),
output_price: mTokToPerToken(entry.output_price),
cache_write_price: mTokToPerToken(entry.cache_write_price),
cache_read_price: mTokToPerToken(entry.cache_read_price),
image_input_price: mTokToPerToken(entry.image_input_price),
image_output_price: mTokToPerToken(entry.image_output_price),
per_request_price: toNullableNumber(entry.per_request_price),
intervals: formIntervalsToAPI(entry.intervals || []),
}));
const { t } = useI18n();
const appStore = useAppStore();
const onboardingStore = useOnboardingStore();
@@ -4913,6 +5013,8 @@ const createForm = reactive({
daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null,
long_context_pricing_enabled: false,
model_pricing: [] as PricingFormEntry[],
// 图片生成计费配置
allow_image_generation: false,
allow_batch_image_generation: false,
@@ -5272,6 +5374,8 @@ const editForm = reactive({
daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null,
long_context_pricing_enabled: false,
model_pricing: [] as PricingFormEntry[],
// 图片生成计费配置
allow_image_generation: false,
allow_batch_image_generation: false,
@@ -5744,6 +5848,8 @@ const closeCreateModal = () => {
createForm.video_price_720p = null;
createForm.video_price_1080p = null;
createForm.video_model_prices = createVideoModelPricesForm();
createForm.long_context_pricing_enabled = false;
createForm.model_pricing = [];
createForm.web_search_price_per_call = null;
createForm.search_price_per_1k = null;
createForm.audio_realtime_price_per_min = null;
@@ -5843,6 +5949,10 @@ const handleCreateGroup = async () => {
// 构建请求数据,包含模型路由配置
const requestData = {
...createGroupForm,
model_pricing: groupPricingToAPI(
createForm.model_pricing,
createForm.platform,
),
daily_limit_usd: normalizeOptionalLimit(
createForm.daily_limit_usd as number | string | null,
),
@@ -5965,6 +6075,9 @@ const handleEdit = async (group: AdminGroup) => {
editForm.daily_limit_usd = group.daily_limit_usd;
editForm.weekly_limit_usd = group.weekly_limit_usd;
editForm.monthly_limit_usd = group.monthly_limit_usd;
editForm.long_context_pricing_enabled =
group.long_context_pricing_enabled ?? false;
editForm.model_pricing = groupPricingFromAPI(group.model_pricing);
editForm.allow_image_generation = group.allow_image_generation ?? false;
editForm.allow_batch_image_generation =
group.allow_batch_image_generation ?? false;
@@ -6069,6 +6182,8 @@ const closeEditModal = () => {
editForm.video_price_720p = null;
editForm.video_price_1080p = null;
editForm.video_model_prices = createVideoModelPricesForm();
editForm.long_context_pricing_enabled = false;
editForm.model_pricing = [];
editForm.web_search_price_per_call = null;
editForm.search_price_per_1k = null;
editForm.audio_realtime_price_per_min = null;
@@ -6101,6 +6216,10 @@ const handleUpdateGroup = async () => {
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const payload = {
...editForm,
model_pricing: groupPricingToAPI(
editForm.model_pricing,
editForm.platform,
),
daily_limit_usd: normalizeOptionalLimit(
editForm.daily_limit_usd as number | string | null,
),