mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-02 08:31:35 +00:00
fix(grok): 完善按模型视频价格矩阵
This commit is contained in:
@@ -1087,6 +1087,45 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700"
|
||||
data-testid="create-grok-video-model-prices"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.groups.videoPricing.modelOverridesTitle") }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.groups.videoPricing.modelOverridesDescription") }}
|
||||
</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="family in videoModelPriceFamilyRows(createForm.video_model_prices)"
|
||||
:key="family.key"
|
||||
class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,minmax(0,7rem))] sm:items-end"
|
||||
>
|
||||
<div class="min-w-0 pb-1 font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{{ family.label }}
|
||||
</div>
|
||||
<label
|
||||
v-for="resolution in grokVideoPriceResolutions"
|
||||
:key="resolution.key"
|
||||
class="block"
|
||||
>
|
||||
<span class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ resolution.label }} ($/s)
|
||||
</span>
|
||||
<input
|
||||
v-model.number="createForm.video_model_prices[family.key][resolution.key]"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
:data-testid="`create-grok-video-price-${family.key}-${resolution.key}`"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t(videoPricingI18nKey("modeHint")) }}
|
||||
</p>
|
||||
@@ -2692,6 +2731,45 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 border-t border-dashed border-gray-200 pt-4 dark:border-dark-700"
|
||||
data-testid="edit-grok-video-model-prices"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.groups.videoPricing.modelOverridesTitle") }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.groups.videoPricing.modelOverridesDescription") }}
|
||||
</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="family in videoModelPriceFamilyRows(editForm.video_model_prices)"
|
||||
:key="family.key"
|
||||
class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,minmax(0,7rem))] sm:items-end"
|
||||
>
|
||||
<div class="min-w-0 pb-1 font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{{ family.label }}
|
||||
</div>
|
||||
<label
|
||||
v-for="resolution in grokVideoPriceResolutions"
|
||||
:key="resolution.key"
|
||||
class="block"
|
||||
>
|
||||
<span class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ resolution.label }} ($/s)
|
||||
</span>
|
||||
<input
|
||||
v-model.number="editForm.video_model_prices[family.key][resolution.key]"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="input"
|
||||
:data-testid="`edit-grok-video-price-${family.key}-${resolution.key}`"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t(videoPricingI18nKey("modeHint")) }}
|
||||
</p>
|
||||
@@ -4219,6 +4297,12 @@ import {
|
||||
supportsVideoPricingPlatform,
|
||||
videoPricingI18nKey,
|
||||
} from "./groupsImagePricing";
|
||||
import {
|
||||
createVideoModelPricesForm,
|
||||
grokVideoPriceResolutions,
|
||||
serializeVideoModelPrices,
|
||||
videoModelPriceFamilyRows,
|
||||
} from "./groupsVideoModelPricing";
|
||||
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
@@ -4719,6 +4803,7 @@ const createForm = reactive({
|
||||
video_price_480p: null as number | null,
|
||||
video_price_720p: null as number | null,
|
||||
video_price_1080p: null as number | null,
|
||||
video_model_prices: createVideoModelPricesForm(),
|
||||
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
|
||||
web_search_price_per_call: null as number | null,
|
||||
// 高峰时段倍率配置
|
||||
@@ -5073,6 +5158,7 @@ const editForm = reactive({
|
||||
video_price_480p: null as number | null,
|
||||
video_price_720p: null as number | null,
|
||||
video_price_1080p: null as number | null,
|
||||
video_model_prices: createVideoModelPricesForm(),
|
||||
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
|
||||
web_search_price_per_call: null as number | null,
|
||||
// 高峰时段倍率配置
|
||||
@@ -5523,6 +5609,7 @@ const closeCreateModal = () => {
|
||||
createForm.video_price_480p = null;
|
||||
createForm.video_price_720p = null;
|
||||
createForm.video_price_1080p = null;
|
||||
createForm.video_model_prices = createVideoModelPricesForm();
|
||||
createForm.web_search_price_per_call = null;
|
||||
createForm.peak_rate_enabled = false;
|
||||
createForm.peak_start = "";
|
||||
@@ -5608,9 +5695,16 @@ const handleCreateGroup = async () => {
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const {
|
||||
video_model_prices: _createFormVideoModelPrices,
|
||||
...createGroupForm
|
||||
} = createForm;
|
||||
const videoModelPrices = serializeVideoModelPrices(
|
||||
createForm.video_model_prices,
|
||||
);
|
||||
// 构建请求数据,包含模型路由配置
|
||||
const requestData = {
|
||||
...createForm,
|
||||
...createGroupForm,
|
||||
daily_limit_usd: normalizeOptionalLimit(
|
||||
createForm.daily_limit_usd as number | string | null,
|
||||
),
|
||||
@@ -5620,6 +5714,9 @@ const handleCreateGroup = async () => {
|
||||
monthly_limit_usd: normalizeOptionalLimit(
|
||||
createForm.monthly_limit_usd as number | string | null,
|
||||
),
|
||||
...(Object.keys(videoModelPrices).length > 0
|
||||
? { video_model_prices: videoModelPrices }
|
||||
: {}),
|
||||
model_routing: convertRoutingRulesToApiFormat(
|
||||
createModelRoutingRules.value,
|
||||
),
|
||||
@@ -5734,6 +5831,9 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.video_price_480p = group.video_price_480p;
|
||||
editForm.video_price_720p = group.video_price_720p;
|
||||
editForm.video_price_1080p = group.video_price_1080p;
|
||||
editForm.video_model_prices = createVideoModelPricesForm(
|
||||
group.video_model_prices,
|
||||
);
|
||||
editForm.web_search_price_per_call = group.web_search_price_per_call ?? null;
|
||||
editForm.peak_rate_enabled = group.peak_rate_enabled ?? false;
|
||||
editForm.peak_start = group.peak_start ?? "";
|
||||
@@ -5814,6 +5914,7 @@ const closeEditModal = () => {
|
||||
editForm.video_price_480p = null;
|
||||
editForm.video_price_720p = null;
|
||||
editForm.video_price_1080p = null;
|
||||
editForm.video_model_prices = createVideoModelPricesForm();
|
||||
editForm.web_search_price_per_call = null;
|
||||
resetMessagesDispatchFormState(editForm);
|
||||
editForm.allow_live = false;
|
||||
@@ -5851,6 +5952,9 @@ const handleUpdateGroup = async () => {
|
||||
monthly_limit_usd: normalizeOptionalLimit(
|
||||
editForm.monthly_limit_usd as number | string | null,
|
||||
),
|
||||
video_model_prices: serializeVideoModelPrices(
|
||||
editForm.video_model_prices,
|
||||
),
|
||||
fallback_group_id:
|
||||
editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
|
||||
fallback_group_id_on_invalid_request:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createVideoModelPricesForm,
|
||||
serializeVideoModelPrices,
|
||||
videoModelPriceFamilyRows
|
||||
} from '../groupsVideoModelPricing'
|
||||
|
||||
describe('Grok video model pricing form', () => {
|
||||
it('provides editable rows for both canonical Grok video families', () => {
|
||||
const form = createVideoModelPricesForm()
|
||||
|
||||
expect(videoModelPriceFamilyRows(form).map(({ key }) => key)).toEqual([
|
||||
'grok-imagine-video',
|
||||
'grok-imagine-video-1.5'
|
||||
])
|
||||
expect(form['grok-imagine-video']['480p']).toBeNull()
|
||||
expect(form['grok-imagine-video-1.5']['1080p']).toBeNull()
|
||||
})
|
||||
|
||||
it('serializes only finite non-negative prices and preserves future families', () => {
|
||||
const form = createVideoModelPricesForm({
|
||||
'grok-imagine-video-2': { '1080p': 0.4 }
|
||||
})
|
||||
form['grok-imagine-video']['480p'] = 0.05
|
||||
form['grok-imagine-video']['720p'] = ''
|
||||
form['grok-imagine-video-1.5']['1080p'] = -1
|
||||
|
||||
expect(serializeVideoModelPrices(form)).toEqual({
|
||||
'grok-imagine-video': { '480p': 0.05 },
|
||||
'grok-imagine-video-2': { '1080p': 0.4 }
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips unknown model families so editing does not discard them', () => {
|
||||
const form = createVideoModelPricesForm({
|
||||
'grok-imagine-video-2': { '480p': 0.2 }
|
||||
})
|
||||
|
||||
expect(videoModelPriceFamilyRows(form).map(({ key }) => key)).toContain(
|
||||
'grok-imagine-video-2'
|
||||
)
|
||||
expect(serializeVideoModelPrices(form)).toMatchObject({
|
||||
'grok-imagine-video-2': { '480p': 0.2 }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
export const grokVideoPriceResolutions = [
|
||||
{ key: '480p', label: '480p' },
|
||||
{ key: '720p', label: '720p' },
|
||||
{ key: '1080p', label: '1080p' }
|
||||
] as const
|
||||
|
||||
export const grokVideoPriceFamilies = [
|
||||
{ key: 'grok-imagine-video', label: 'grok-imagine-video' },
|
||||
{ key: 'grok-imagine-video-1.5', label: 'grok-imagine-video-1.5' }
|
||||
] as const
|
||||
|
||||
export type VideoModelPrices = Record<string, Record<string, number>>
|
||||
export type VideoModelPricesForm = Record<string, Record<string, number | string | null>>
|
||||
|
||||
function normalizeFamily(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizePrice(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const price = Number(value)
|
||||
return Number.isFinite(price) && price >= 0 ? price : null
|
||||
}
|
||||
|
||||
function emptyTiers(): Record<string, number | string | null> {
|
||||
return Object.fromEntries(grokVideoPriceResolutions.map(({ key }) => [key, null]))
|
||||
}
|
||||
|
||||
// Keep unknown families from an existing group so a future backend catalog is
|
||||
// not silently discarded when an operator edits another group setting.
|
||||
export function createVideoModelPricesForm(
|
||||
prices?: VideoModelPrices | null
|
||||
): VideoModelPricesForm {
|
||||
const form: VideoModelPricesForm = {}
|
||||
|
||||
for (const [rawFamily, rawTiers] of Object.entries(prices ?? {})) {
|
||||
const family = normalizeFamily(rawFamily)
|
||||
if (!family || !rawTiers || typeof rawTiers !== 'object') continue
|
||||
form[family] = emptyTiers()
|
||||
for (const [rawResolution, rawPrice] of Object.entries(rawTiers)) {
|
||||
const price = normalizePrice(rawPrice)
|
||||
if (price !== null) form[family][rawResolution.trim().toLowerCase()] = price
|
||||
}
|
||||
}
|
||||
|
||||
for (const { key } of grokVideoPriceFamilies) {
|
||||
form[key] ??= emptyTiers()
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
export function serializeVideoModelPrices(form: VideoModelPricesForm): VideoModelPrices {
|
||||
const result: VideoModelPrices = {}
|
||||
for (const [rawFamily, tiers] of Object.entries(form)) {
|
||||
const family = normalizeFamily(rawFamily)
|
||||
if (!family || !tiers || typeof tiers !== 'object') continue
|
||||
|
||||
const normalizedTiers: Record<string, number> = {}
|
||||
for (const [rawResolution, rawPrice] of Object.entries(tiers)) {
|
||||
const resolution = rawResolution.trim().toLowerCase()
|
||||
const price = normalizePrice(rawPrice)
|
||||
if (resolution && price !== null) normalizedTiers[resolution] = price
|
||||
}
|
||||
if (Object.keys(normalizedTiers).length > 0) result[family] = normalizedTiers
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function videoModelPriceFamilyRows(form: VideoModelPricesForm) {
|
||||
const known = new Set<string>(grokVideoPriceFamilies.map(({ key }) => key))
|
||||
const extra = Object.keys(form)
|
||||
.map(normalizeFamily)
|
||||
.filter((family) => family && !known.has(family))
|
||||
.sort()
|
||||
.map((key) => ({ key, label: key }))
|
||||
return [...grokVideoPriceFamilies, ...extra]
|
||||
}
|
||||
Reference in New Issue
Block a user