fix(grok): 完善按模型视频价格矩阵

This commit is contained in:
IanShaw027
2026-08-07 14:42:00 +08:00
parent eb2e73df3d
commit 2c8836e40b
10 changed files with 277 additions and 20 deletions
+1 -3
View File
@@ -299,12 +299,10 @@ func ResolveDefaultTextModel(model string, defaultText ...string) string {
func CanonicalImagineVideoModel(model string) string {
m := strings.ToLower(StripGrokProviderPrefix(model))
switch {
case m == "" || m == DefaultImagineVideoModel:
case m == "" || m == DefaultImagineVideoModel || m == "grok-imagine-video-preview":
return DefaultImagineVideoModel
case strings.HasPrefix(m, "grok-imagine-video-1.5") || m == "grok-video-1.5":
return DefaultImagineVideo15Model
case strings.HasPrefix(m, "grok-imagine-video"):
return DefaultImagineVideoModel
default:
return m
}
+1
View File
@@ -44,6 +44,7 @@ func TestCanonicalImagineVideoModel(t *testing.T) {
require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("grok-imagine-video-1.5"))
require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("grok-imagine-video-1.5-preview"))
require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("xai/grok-video-1.5"))
require.Equal(t, "grok-imagine-video-2", CanonicalImagineVideoModel("grok-imagine-video-2"))
}
func TestIsGrokModelID(t *testing.T) {
+10 -16
View File
@@ -18,14 +18,18 @@ func CanonicalGrokImagineVideoPriceFamily(model string) string {
if model == "" {
return ""
}
// Prefer shared xAI helper when available.
// Prefer shared xAI helper for known aliases. Keep future native Imagine
// models distinct so operators can assign them independent prices.
if c := xai.CanonicalImagineVideoModel(model); c != "" {
switch {
case strings.HasPrefix(c, "grok-imagine-video-1.5"):
switch c {
case xai.DefaultImagineVideo15Model:
return VideoPriceFamilyGrokImagineVideo15
case strings.HasPrefix(c, "grok-imagine-video"):
case xai.DefaultImagineVideoModel:
return VideoPriceFamilyGrokImagineVideo
}
if strings.HasPrefix(c, "grok-imagine-video-") {
return c
}
}
m := strings.ToLower(strings.TrimSpace(model))
for _, prefix := range []string{"xai/", "x-ai/", "grok/"} {
@@ -38,8 +42,8 @@ func CanonicalGrokImagineVideoPriceFamily(model string) string {
case m == "grok-imagine-video-1.5" || m == "grok-imagine-video-1.5-preview" ||
m == "grok-video-1.5" || strings.Contains(m, "video-1.5"):
return VideoPriceFamilyGrokImagineVideo15
case m == "grok-imagine-video" || m == "grok-video" || m == "grok-video-latest" ||
strings.HasPrefix(m, "grok-imagine-video") || strings.HasPrefix(m, "grok-video"):
case m == "grok-imagine-video" || m == "grok-imagine-video-preview" ||
m == "grok-video" || m == "grok-video-latest":
return VideoPriceFamilyGrokImagineVideo
default:
return ""
@@ -112,15 +116,5 @@ func LookupVideoModelPrice(prices map[string]map[string]float64, model, resoluti
p := price
return &p
}
for _, candidate := range []string{
VideoBillingResolution1080P,
VideoBillingResolution720P,
VideoBillingResolution480P,
} {
if price, ok := tierPrices[candidate]; ok {
p := price
return &p
}
}
return nil
}
@@ -12,6 +12,8 @@ func TestCanonicalGrokImagineVideoPriceFamily(t *testing.T) {
require.Equal(t, VideoPriceFamilyGrokImagineVideo15, CanonicalGrokImagineVideoPriceFamily("grok-imagine-video-1.5"))
require.Equal(t, VideoPriceFamilyGrokImagineVideo15, CanonicalGrokImagineVideoPriceFamily("grok-imagine-video-1.5-preview"))
require.Equal(t, VideoPriceFamilyGrokImagineVideo15, CanonicalGrokImagineVideoPriceFamily("xai/grok-video-1.5"))
require.Equal(t, "grok-imagine-video-2", CanonicalGrokImagineVideoPriceFamily("grok-imagine-video-2"))
require.Equal(t, "grok-imagine-video-2", CanonicalGrokImagineVideoPriceFamily("xai/grok-imagine-video-2"))
}
func TestNormalizeAndLookupVideoModelPrices(t *testing.T) {
@@ -19,11 +21,13 @@ func TestNormalizeAndLookupVideoModelPrices(t *testing.T) {
raw := map[string]map[string]float64{
"grok-imagine-video-1.5-preview": {"480p": 0.08, "720p": 0.14},
"grok-imagine-video": {"480p": 0.05},
"grok-imagine-video-2": {"1080p": 0.4},
}
norm := NormalizeVideoModelPrices(raw)
require.NotNil(t, norm)
require.Contains(t, norm, VideoPriceFamilyGrokImagineVideo15)
require.Contains(t, norm, VideoPriceFamilyGrokImagineVideo)
require.Contains(t, norm, "grok-imagine-video-2")
p15 := LookupVideoModelPrice(norm, "grok-imagine-video-1.5", "480p")
require.NotNil(t, p15)
@@ -32,7 +36,29 @@ func TestNormalizeAndLookupVideoModelPrices(t *testing.T) {
pBase := LookupVideoModelPrice(norm, "grok-imagine-video", "480p")
require.NotNil(t, pBase)
require.InDelta(t, 0.05, *pBase, 1e-9)
// A missing model-specific tier must fall back to the flat tier price,
// rather than borrowing another model-specific resolution.
require.Nil(t, LookupVideoModelPrice(norm, "grok-imagine-video", "720p"))
p2 := LookupVideoModelPrice(norm, "grok-imagine-video-2", "1080p")
require.NotNil(t, p2)
require.InDelta(t, 0.4, *p2, 1e-9)
// Unmatched model → nil (caller falls back to flat columns / defaults).
require.Nil(t, LookupVideoModelPrice(norm, "unknown-model", "480p"))
}
func TestVideoModelPriceMissingTierFallsBackToFlatTierPrice(t *testing.T) {
t.Parallel()
flat720P := 0.7
service := &BillingService{}
result := service.CalculateVideoCost("grok-imagine-video", "720p", 1, 1, &VideoPriceConfig{
Price720P: &flat720P,
ModelPrices: map[string]map[string]float64{
VideoPriceFamilyGrokImagineVideo: {VideoBillingResolution480P: 0.05},
},
}, 1)
require.InDelta(t, flat720P, result.TotalCost, 1e-9)
}
@@ -985,6 +985,8 @@ export default {
title: 'Video Generation Pricing',
description:
'Configure Grok video generation prices in USD per second of output video. Leave empty to use the default per-second rates (grok-imagine-video: $0.05/s 480p, $0.07/s 720p; video-1.5: $0.08/s 480p, $0.14/s 720p, $0.25/s 1080p).',
modelOverridesTitle: 'Per-model video price overrides',
modelOverridesDescription: 'Each populated cell overrides the flat resolution price for that model family. Preview and legacy aliases for video-1.5 use the same family; empty cells fall back to the flat resolution price.',
independentMultiplier: 'Use independent video multiplier',
videoMultiplier: 'Video multiplier',
modeHint:
@@ -982,6 +982,8 @@ export default {
title: '视频生成计费',
description:
'配置 Grok 视频生成的每秒单价(USD/秒),留空则使用默认每秒价(grok-imagine-video:480p $0.05/s、720p $0.07/s;video-1.5:480p $0.08/s、720p $0.14/s、1080p $0.25/s)',
modelOverridesTitle: '按模型覆盖视频价格',
modelOverridesDescription: '已填写的单元格会覆盖该模型族的平面分辨率价格。video-1.5 的 preview 与 legacy 别名共用同一模型族;留空则回退到平面分辨率价格。',
independentMultiplier: '视频倍率独立',
videoMultiplier: '视频独立倍率',
modeHint:
+6
View File
@@ -522,6 +522,8 @@ export interface PaginationConfig {
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'composite'
export type VideoModelPrices = Record<string, Record<string, number>>
export type SubscriptionType = 'standard' | 'subscription'
export interface OpenAIMessagesDispatchModelConfig {
@@ -566,6 +568,8 @@ export interface Group {
video_price_480p: number | null
video_price_720p: number | null
video_price_1080p: number | null
// Optional model-family x resolution overrides for Grok video pricing.
video_model_prices?: VideoModelPrices
// Codex 网页搜索单次价格(USD/次);null 表示使用默认价 0.01
web_search_price_per_call: number | null
// 高峰时段倍率配置
@@ -766,6 +770,7 @@ export interface CreateGroupRequest {
video_price_480p?: number | null
video_price_720p?: number | null
video_price_1080p?: number | null
video_model_prices?: VideoModelPrices
web_search_price_per_call?: number | null
peak_rate_enabled?: boolean
peak_start?: string
@@ -821,6 +826,7 @@ export interface UpdateGroupRequest {
video_price_480p?: number | null
video_price_720p?: number | null
video_price_1080p?: number | null
video_model_prices?: VideoModelPrices
web_search_price_per_call?: number | null
peak_rate_enabled?: boolean
peak_start?: string
+105 -1
View File
@@ -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]
}