mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-02 00:30:32 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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)。留空表示未配置。',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user