mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
fix(grok): 完善按模型视频价格矩阵
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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