feat(grok): 网关 Voice TTS/STT/Realtime 与分组音频定价

- 中继 xAI Voice HTTP(/tts /stt /custom-voices)与 Realtime WS(/realtime)
  仅 Grok 分组可用;账号选调度沿用 chat 能力,不依赖创作中心
- Voice URL 固定走 api.x.ai(CLI proxy base 自动回落)
- 分组级 audio_realtime / TTS / STT 单价:schema + migration 218 +
  admin API + GroupsView 表单与 i18n(无创作中心 UI)
This commit is contained in:
IanShaw027
2026-08-07 15:08:39 +08:00
parent a397724f69
commit 99ad01f6de
31 changed files with 1908 additions and 26 deletions
@@ -994,6 +994,14 @@ export default {
finalPricePreview: 'Final per-second price preview',
notConfigured: 'Not configured'
},
voicePricing: {
title: 'Grok Voice Pricing',
description: 'Optional per-group prices for Voice realtime / TTS / STT (USD). Leave empty to leave unpriced.',
audioRealtimePerMin: 'Realtime price per minute (USD)',
audioTtsPerMillionChars: 'TTS price per million chars (USD)',
audioSttPerHour: 'STT price per hour (USD)',
pricePlaceholder: 'optional'
},
webSearchPricing: {
title: 'Codex Web Search Pricing',
pricePerCall: 'Price per search call (USD)',
@@ -991,6 +991,14 @@ export default {
finalPricePreview: '最终每秒价格预览',
notConfigured: '未配置'
},
voicePricing: {
title: 'Grok Voice 定价',
description: '分组级 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。',
audioRealtimePerMin: 'Realtime 每分钟价格(USD)',
audioTtsPerMillionChars: 'TTS 每百万字符价格(USD)',
audioSttPerHour: 'STT 每小时价格(USD)',
pricePlaceholder: '可选'
},
webSearchPricing: {
title: 'Codex 网页搜索计费',
pricePerCall: '搜索单次价格(USD/次)',
+10
View File
@@ -572,6 +572,10 @@ export interface Group {
video_model_prices?: VideoModelPrices
// Codex 网页搜索单次价格(USD/次);null 表示使用默认价 0.01
web_search_price_per_call: number | null
// Grok Voice 显式定价(分组级)
audio_realtime_price_per_min: number | null
audio_tts_price_per_million_chars: number | null
audio_stt_price_per_hour: number | null
// 高峰时段倍率配置
peak_rate_enabled: boolean
peak_start: string
@@ -772,6 +776,9 @@ export interface CreateGroupRequest {
video_price_1080p?: number | null
video_model_prices?: VideoModelPrices
web_search_price_per_call?: number | null
audio_realtime_price_per_min?: number | null
audio_tts_price_per_million_chars?: number | null
audio_stt_price_per_hour?: number | null
peak_rate_enabled?: boolean
peak_start?: string
peak_end?: string
@@ -828,6 +835,9 @@ export interface UpdateGroupRequest {
video_price_1080p?: number | null
video_model_prices?: VideoModelPrices
web_search_price_per_call?: number | null
audio_realtime_price_per_min?: number | null
audio_tts_price_per_million_chars?: number | null
audio_stt_price_per_hour?: number | null
peak_rate_enabled?: boolean
peak_start?: string
peak_end?: string
+135
View File
@@ -1483,6 +1483,57 @@
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="createForm.platform === 'grok'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t("admin.groups.voicePricing.title") }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.voicePricing.description") }}
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioRealtimePerMin") }}</label>
<input
v-model.number="createForm.audio_realtime_price_per_min"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-realtime-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioTtsPerMillionChars") }}</label>
<input
v-model.number="createForm.audio_tts_price_per_million_chars"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-tts-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioSttPerHour") }}</label>
<input
v-model.number="createForm.audio_stt_price_per_hour"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="create-audio-stt-price"
/>
</div>
</div>
</div>
<!-- OpenAI Live 开关(仅 openai 平台) -->
<div
v-if="createForm.platform === 'openai'"
@@ -3123,6 +3174,57 @@
</div>
</div>
<!-- Grok Voice 显式定价(仅 grok 平台) -->
<div
v-if="editForm.platform === 'grok'"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t("admin.groups.voicePricing.title") }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t("admin.groups.voicePricing.description") }}
</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioRealtimePerMin") }}</label>
<input
v-model.number="editForm.audio_realtime_price_per_min"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-realtime-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioTtsPerMillionChars") }}</label>
<input
v-model.number="editForm.audio_tts_price_per_million_chars"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-tts-price"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.voicePricing.audioSttPerHour") }}</label>
<input
v-model.number="editForm.audio_stt_price_per_hour"
type="number"
step="0.000001"
min="0"
class="input"
:placeholder="t('admin.groups.voicePricing.pricePlaceholder')"
data-testid="edit-audio-stt-price"
/>
</div>
</div>
</div>
<!-- OpenAI Live 开关(仅 openai 平台) -->
<div
v-if="editForm.platform === 'openai'"
@@ -4806,6 +4908,9 @@ const createForm = reactive({
video_model_prices: createVideoModelPricesForm(),
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
web_search_price_per_call: null as number | null,
audio_realtime_price_per_min: null as number | null,
audio_tts_price_per_million_chars: null as number | null,
audio_stt_price_per_hour: null as number | null,
// 高峰时段倍率配置
peak_rate_enabled: false,
peak_start: "",
@@ -5161,6 +5266,9 @@ const editForm = reactive({
video_model_prices: createVideoModelPricesForm(),
// Codex 网页搜索按次计费(仅 openai 平台使用);null = 使用默认价 0.01
web_search_price_per_call: null as number | null,
audio_realtime_price_per_min: null as number | null,
audio_tts_price_per_million_chars: null as number | null,
audio_stt_price_per_hour: null as number | null,
// 高峰时段倍率配置
peak_rate_enabled: false,
peak_start: "",
@@ -5611,6 +5719,9 @@ const closeCreateModal = () => {
createForm.video_price_1080p = null;
createForm.video_model_prices = createVideoModelPricesForm();
createForm.web_search_price_per_call = null;
createForm.audio_realtime_price_per_min = null;
createForm.audio_tts_price_per_million_chars = null;
createForm.audio_stt_price_per_hour = null;
createForm.peak_rate_enabled = false;
createForm.peak_start = "";
createForm.peak_end = "";
@@ -5775,6 +5886,15 @@ const handleCreateGroup = async () => {
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.audio_realtime_price_per_min = emptyToNull(
requestData.audio_realtime_price_per_min,
);
requestData.audio_tts_price_per_million_chars = emptyToNull(
requestData.audio_tts_price_per_million_chars,
);
requestData.audio_stt_price_per_hour = emptyToNull(
requestData.audio_stt_price_per_hour,
);
requestData.web_search_price_per_call = emptyToNull(
requestData.web_search_price_per_call,
);
@@ -5835,6 +5955,9 @@ const handleEdit = async (group: AdminGroup) => {
group.video_model_prices,
);
editForm.web_search_price_per_call = group.web_search_price_per_call ?? null;
editForm.audio_realtime_price_per_min = group.audio_realtime_price_per_min ?? null;
editForm.audio_tts_price_per_million_chars = group.audio_tts_price_per_million_chars ?? null;
editForm.audio_stt_price_per_hour = group.audio_stt_price_per_hour ?? null;
editForm.peak_rate_enabled = group.peak_rate_enabled ?? false;
editForm.peak_start = group.peak_start ?? "";
editForm.peak_end = group.peak_end ?? "";
@@ -5916,6 +6039,9 @@ const closeEditModal = () => {
editForm.video_price_1080p = null;
editForm.video_model_prices = createVideoModelPricesForm();
editForm.web_search_price_per_call = null;
editForm.audio_realtime_price_per_min = null;
editForm.audio_tts_price_per_million_chars = null;
editForm.audio_stt_price_per_hour = null;
resetMessagesDispatchFormState(editForm);
editForm.allow_live = false;
resetModelsListState(editModelsListState);
@@ -6021,6 +6147,15 @@ const handleUpdateGroup = async () => {
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.audio_realtime_price_per_min = emptyPriceToClear(
payload.audio_realtime_price_per_min,
);
payload.audio_tts_price_per_million_chars = emptyPriceToClear(
payload.audio_tts_price_per_million_chars,
);
payload.audio_stt_price_per_hour = emptyPriceToClear(
payload.audio_stt_price_per_hour,
);
payload.web_search_price_per_call = emptyPriceToClear(
payload.web_search_price_per_call,
);