fix: bill Grok video per second and harden video usage logging

Follow-up fixes for the #3775 audit findings:

- Bill Grok video generation per second of output, matching the xAI rate
  card: parse the request duration (1-15s, upstream default 8s) and compute
  cost as per-second price x duration x count. The built-in rate card values
  were already xAI per-second prices but were previously charged per video,
  undercharging up to 15x with a user-controlled duration.
- Group video_price_* fields are now documented and surfaced as per-second
  rates (USD/s); admin UI labels, placeholders and hints updated accordingly.
- Persist video_count/video_resolution/video_duration_seconds on usage_logs
  (migration 172) so video billing is auditable, and exempt any row with
  video_count > 0 from the image_size check constraint: a video billed via a
  token-mode channel price produces billing_mode='token' with image_count=1
  and no image_size, which the previous constraint rejected, dropping the
  whole billing transaction.
- Only refetch the group in apiKeyWithFreshGroupMediaPricing when the group
  object actually looks like it is missing media pricing fields (both media
  multipliers zero and all prices nil, impossible for a normally loaded
  group), removing a per-usage DB query for groups without overrides.
- Frontend: drop the unused admin.groups.mediaPricing locale block, map
  cleared price inputs to null (create) / -1 (update, cleared via backend
  normalizePrice) instead of sending "" that failed *float64 unmarshalling,
  and align video price placeholders with the text-to-video default model
  (grok-imagine-video 0.05/0.07, 1080p only on 1.5 at 0.25).
This commit is contained in:
shaw
2026-07-09 15:38:59 +08:00
parent 9ba0fb3084
commit d4952154ff
28 changed files with 1396 additions and 143 deletions
+4 -15
View File
@@ -841,26 +841,15 @@ export default {
finalPricePreview: 'Final per-image price preview',
notConfigured: 'Not configured'
},
mediaPricing: {
title: 'Image / Video Generation Pricing',
description:
'Configure Grok image and video generation access plus base media prices. Leave empty to use default prices.',
allowImageGeneration: 'Allow image and video generation for this group',
independentMultiplier: 'Use independent media multiplier',
imageMultiplier: 'Media multiplier',
modeHint:
'By default, Grok media billing uses media price × current effective group multiplier. Independent mode uses media price × media multiplier. One video generation is billed as one media unit.',
finalPricePreview: 'Final per-media-unit price preview',
notConfigured: 'Not configured'
},
videoPricing: {
title: 'Video Generation Pricing',
description: 'Configure Grok video generation base prices. Leave empty to use default video prices.',
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).',
independentMultiplier: 'Use independent video multiplier',
videoMultiplier: 'Video multiplier',
modeHint:
'By default, video billing uses video price × current effective group multiplier. Independent mode uses video price × video multiplier.',
finalPricePreview: 'Final per-video price preview',
'Videos are billed per second: per-second price × duration (1-15s, default 8s). By default the current effective group multiplier applies; independent mode uses the video multiplier instead.',
finalPricePreview: 'Final per-second price preview',
notConfigured: 'Not configured'
},
peakRate: {
+4 -14
View File
@@ -919,25 +919,15 @@ export default {
finalPricePreview: '最终单张价格预览',
notConfigured: '未配置'
},
mediaPricing: {
title: '图片/视频生成计费',
description: '配置 Grok 图片和视频生成能力及媒体基础单价,留空则使用默认价格',
allowImageGeneration: '允许当前分组生图和视频生成',
independentMultiplier: '媒体倍率独立',
imageMultiplier: '媒体独立倍率',
modeHint:
'默认关闭独立倍率时,Grok 媒体费用 = 媒体价格 × 当前分组有效倍率;开启独立倍率后,Grok 媒体费用 = 媒体价格 × 媒体独立倍率。一次视频生成按 1 个媒体单位计费。',
finalPricePreview: '最终单次媒体价格预览',
notConfigured: '未配置'
},
videoPricing: {
title: '视频生成计费',
description: '配置 Grok 视频生成基础单价,留空则使用默认视频价格',
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)',
independentMultiplier: '视频倍率独立',
videoMultiplier: '视频独立倍率',
modeHint:
'默认关闭独立倍率时,视频费用 = 视频价格 × 当前分组有效倍率;开启独立倍率后,视频费用 = 视频价格 × 视频独立倍率。',
finalPricePreview: '最终单次视频价格预览',
'视频按秒计费:费用 = 每秒价格 × 时长(1-15 秒,未指定默认 8 秒)。默认叠加当前分组有效倍率;开启独立倍率后改用视频独立倍率。',
finalPricePreview: '最终每秒价格预览',
notConfigured: '未配置'
},
peakRate: {
+24 -6
View File
@@ -1010,7 +1010,7 @@
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">480p ($)</label>
<label class="input-label">480p ($/s)</label>
<input
v-model.number="createForm.video_price_480p"
type="number"
@@ -1021,7 +1021,7 @@
/>
</div>
<div>
<label class="input-label">720p ($)</label>
<label class="input-label">720p ($/s)</label>
<input
v-model.number="createForm.video_price_720p"
type="number"
@@ -1032,7 +1032,7 @@
/>
</div>
<div>
<label class="input-label">1080p ($)</label>
<label class="input-label">1080p ($/s)</label>
<input
v-model.number="createForm.video_price_1080p"
type="number"
@@ -2489,7 +2489,7 @@
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">480p ($)</label>
<label class="input-label">480p ($/s)</label>
<input
v-model.number="editForm.video_price_480p"
type="number"
@@ -2500,7 +2500,7 @@
/>
</div>
<div>
<label class="input-label">720p ($)</label>
<label class="input-label">720p ($/s)</label>
<input
v-model.number="editForm.video_price_720p"
type="number"
@@ -2511,7 +2511,7 @@
/>
</div>
<div>
<label class="input-label">1080p ($)</label>
<label class="input-label">1080p ($/s)</label>
<input
v-model.number="editForm.video_price_1080p"
type="number"
@@ -4718,6 +4718,14 @@ const handleCreateGroup = async () => {
requestData.video_rate_multiplier = normalizeRateMultiplier(
requestData.video_rate_multiplier,
);
// 媒体价格输入清空时 v-model.number 产生 "",直接提交会被后端 *float64 反序列化拒绝(400),
// 创建时按"未配置"(null)处理。
requestData.image_price_1k = emptyToNull(requestData.image_price_1k);
requestData.image_price_2k = emptyToNull(requestData.image_price_2k);
requestData.image_price_4k = emptyToNull(requestData.image_price_4k);
requestData.video_price_480p = emptyToNull(requestData.video_price_480p);
requestData.video_price_720p = emptyToNull(requestData.video_price_720p);
requestData.video_price_1080p = emptyToNull(requestData.video_price_1080p);
requestData.peak_rate_enabled = createForm.peak_rate_enabled;
requestData.peak_start = createForm.peak_start;
requestData.peak_end = createForm.peak_end;
@@ -4896,6 +4904,16 @@ const handleUpdateGroup = async () => {
payload.video_rate_multiplier = normalizeRateMultiplier(
payload.video_rate_multiplier,
);
// 媒体价格输入清空时 v-model.number 产生 "",直接提交会被后端 *float64 反序列化拒绝(400)。
// 更新语义中 null 表示"不修改",因此清空后的字段发送 -1:后端 normalizePrice 将负价归一为
// NULL,从而真正清除已配置的价格。
const emptyPriceToClear = (v: any) => (v === "" || v === null ? -1 : v);
payload.image_price_1k = emptyPriceToClear(payload.image_price_1k);
payload.image_price_2k = emptyPriceToClear(payload.image_price_2k);
payload.image_price_4k = emptyPriceToClear(payload.image_price_4k);
payload.video_price_480p = emptyPriceToClear(payload.video_price_480p);
payload.video_price_720p = emptyPriceToClear(payload.video_price_720p);
payload.video_price_1080p = emptyPriceToClear(payload.video_price_1080p);
payload.peak_rate_enabled = editForm.peak_rate_enabled;
payload.peak_start = editForm.peak_start;
payload.peak_end = editForm.peak_end;
@@ -37,8 +37,10 @@ describe("groups image pricing platform support", () => {
it("uses Grok media defaults instead of generic image fallback placeholders", () => {
expect(getImagePricePlaceholder("grok", "image_price_1k")).toBe("0.02");
expect(getImagePricePlaceholder("grok", "image_price_2k")).toBe("0.02");
expect(getVideoPricePlaceholder("grok", "video_price_480p")).toBe("0.08");
expect(getVideoPricePlaceholder("grok", "video_price_720p")).toBe("0.14");
// 视频 placeholder 为每秒单价:480p/720p 取 grok-imagine-video 官方每秒价,
// 1080p 仅 video-1.5 支持、取 1.5 每秒价。
expect(getVideoPricePlaceholder("grok", "video_price_480p")).toBe("0.05");
expect(getVideoPricePlaceholder("grok", "video_price_720p")).toBe("0.07");
expect(getVideoPricePlaceholder("grok", "video_price_1080p")).toBe("0.25");
});
@@ -39,13 +39,15 @@ const defaultImagePricePlaceholders: Record<
},
};
// 视频价为每秒单价(USD/s)。480p/720p 取 grok-imagine-video(文生视频实际走该模型)的
// 官方每秒价;1080p 仅 grok-imagine-video-1.5 图生视频支持,取 1.5 的每秒价。
const defaultVideoPricePlaceholders: Record<
string,
Record<VideoPricingTierKey, string>
> = {
grok: {
video_price_480p: "0.08",
video_price_720p: "0.14",
video_price_480p: "0.05",
video_price_720p: "0.07",
video_price_1080p: "0.25",
},
};