diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index c60c400ac3..2deec44c22 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -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 } diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go index 6ed61f3e7b..7319f7efd9 100644 --- a/backend/internal/pkg/xai/models_test.go +++ b/backend/internal/pkg/xai/models_test.go @@ -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) { diff --git a/backend/internal/service/video_billing.go b/backend/internal/service/video_billing.go index 97950cad37..ec2c8e1798 100644 --- a/backend/internal/service/video_billing.go +++ b/backend/internal/service/video_billing.go @@ -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 } diff --git a/backend/internal/service/video_billing_test.go b/backend/internal/service/video_billing_test.go index 0130c9cca9..69db19430b 100644 --- a/backend/internal/service/video_billing_test.go +++ b/backend/internal/service/video_billing_test.go @@ -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) +} diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index f74785d4fb..4429a3adc1 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -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: diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 04d158971f..1ff5b3555e 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -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: diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 80cd0d3151..28d94c6dad 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -522,6 +522,8 @@ export interface PaginationConfig { export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'composite' +export type VideoModelPrices = Record> + 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 diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 89eee0e9f8..f710e4b96e 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1087,6 +1087,45 @@ /> +
+

+ {{ t("admin.groups.videoPricing.modelOverridesTitle") }} +

+

+ {{ t("admin.groups.videoPricing.modelOverridesDescription") }} +

+
+
+
+ {{ family.label }} +
+ +
+
+

{{ t(videoPricingI18nKey("modeHint")) }}

@@ -2692,6 +2731,45 @@ /> +
+

+ {{ t("admin.groups.videoPricing.modelOverridesTitle") }} +

+

+ {{ t("admin.groups.videoPricing.modelOverridesDescription") }} +

+
+
+
+ {{ family.label }} +
+ +
+
+

{{ t(videoPricingI18nKey("modeHint")) }}

@@ -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: diff --git a/frontend/src/views/admin/__tests__/groupsVideoModelPricing.spec.ts b/frontend/src/views/admin/__tests__/groupsVideoModelPricing.spec.ts new file mode 100644 index 0000000000..327de94cc5 --- /dev/null +++ b/frontend/src/views/admin/__tests__/groupsVideoModelPricing.spec.ts @@ -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 } + }) + }) +}) diff --git a/frontend/src/views/admin/groupsVideoModelPricing.ts b/frontend/src/views/admin/groupsVideoModelPricing.ts new file mode 100644 index 0000000000..ae5c77a332 --- /dev/null +++ b/frontend/src/views/admin/groupsVideoModelPricing.ts @@ -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> +export type VideoModelPricesForm = Record> + +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 { + 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 = {} + 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(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] +}