Browse Source

Merge branch 'gemini-audio-billing' into alpha

creamlike1024 7 months ago
parent
commit
4d18b263dd

+ 10 - 4
relay/channel/gemini/dto.go

@@ -112,10 +112,16 @@ type GeminiChatResponse struct {
 }
 
 type GeminiUsageMetadata struct {
-	PromptTokenCount     int `json:"promptTokenCount"`
-	CandidatesTokenCount int `json:"candidatesTokenCount"`
-	TotalTokenCount      int `json:"totalTokenCount"`
-	ThoughtsTokenCount   int `json:"thoughtsTokenCount"`
+	PromptTokenCount     int                         `json:"promptTokenCount"`
+	CandidatesTokenCount int                         `json:"candidatesTokenCount"`
+	TotalTokenCount      int                         `json:"totalTokenCount"`
+	ThoughtsTokenCount   int                         `json:"thoughtsTokenCount"`
+	PromptTokensDetails  []GeminiPromptTokensDetails `json:"promptTokensDetails"`
+}
+
+type GeminiPromptTokensDetails struct {
+	Modality   string `json:"modality"`
+	TokenCount int    `json:"tokenCount"`
 }
 
 // Imagen related structs

+ 18 - 1
relay/channel/gemini/relay-gemini-native.go

@@ -55,6 +55,16 @@ func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *rela
 		TotalTokens:      geminiResponse.UsageMetadata.TotalTokenCount,
 	}
 
+	usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
+
+	for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
+		if detail.Modality == "AUDIO" {
+			usage.PromptTokensDetails.AudioTokens = detail.TokenCount
+		} else if detail.Modality == "TEXT" {
+			usage.PromptTokensDetails.TextTokens = detail.TokenCount
+		}
+	}
+
 	// 直接返回 Gemini 原生格式的 JSON 响应
 	jsonResponse, err := json.Marshal(geminiResponse)
 	if err != nil {
@@ -100,6 +110,14 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
 			usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
 			usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
 			usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
+			usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
+			for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
+				if detail.Modality == "AUDIO" {
+					usage.PromptTokensDetails.AudioTokens = detail.TokenCount
+				} else if detail.Modality == "TEXT" {
+					usage.PromptTokensDetails.TextTokens = detail.TokenCount
+				}
+			}
 		}
 
 		// 直接发送 GeminiChatResponse 响应
@@ -118,7 +136,6 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
 	}
 
 	// 计算最终使用量
-	usage.PromptTokensDetails.TextTokens = usage.PromptTokens
 	usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
 
 	// 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为

+ 17 - 2
relay/channel/gemini/relay-gemini.go

@@ -313,13 +313,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
 				if part.GetInputAudio().Data == "" {
 					return nil, fmt.Errorf("only base64 audio is supported in gemini")
 				}
-				format, base64String, err := service.DecodeBase64FileData(part.GetInputAudio().Data)
+				base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data)
 				if err != nil {
 					return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
 				}
 				parts = append(parts, GeminiPart{
 					InlineData: &GeminiInlineData{
-						MimeType: format,
+						MimeType: "audio/" + part.GetInputAudio().Format,
 						Data:     base64String,
 					},
 				})
@@ -771,6 +771,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom
 			usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
 			usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
 			usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
+			for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
+				if detail.Modality == "AUDIO" {
+					usage.PromptTokensDetails.AudioTokens = detail.TokenCount
+				} else if detail.Modality == "TEXT" {
+					usage.PromptTokensDetails.TextTokens = detail.TokenCount
+				}
+			}
 		}
 		err = helper.ObjectData(c, response)
 		if err != nil {
@@ -845,6 +852,14 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re
 	usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
 	usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
 
+	for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
+		if detail.Modality == "AUDIO" {
+			usage.PromptTokensDetails.AudioTokens = detail.TokenCount
+		} else if detail.Modality == "TEXT" {
+			usage.PromptTokensDetails.TextTokens = detail.TokenCount
+		}
+	}
+
 	fullTextResponse.Usage = usage
 	jsonResponse, err := json.Marshal(fullTextResponse)
 	if err != nil {

+ 38 - 9
relay/relay-text.go

@@ -352,6 +352,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	promptTokens := usage.PromptTokens
 	cacheTokens := usage.PromptTokensDetails.CachedTokens
 	imageTokens := usage.PromptTokensDetails.ImageTokens
+	audioTokens := usage.PromptTokensDetails.AudioTokens
 	completionTokens := usage.CompletionTokens
 	modelName := relayInfo.OriginModelName
 
@@ -367,6 +368,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	dPromptTokens := decimal.NewFromInt(int64(promptTokens))
 	dCacheTokens := decimal.NewFromInt(int64(cacheTokens))
 	dImageTokens := decimal.NewFromInt(int64(imageTokens))
+	dAudioTokens := decimal.NewFromInt(int64(audioTokens))
 	dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
 	dCompletionRatio := decimal.NewFromFloat(completionRatio)
 	dCacheRatio := decimal.NewFromFloat(cacheRatio)
@@ -412,23 +414,43 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 			dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice).
 				Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
 				Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
-			extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 $%s",
+			extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 %s",
 				fileSearchTool.CallCount, dFileSearchQuota.String())
 		}
 	}
 
 	var quotaCalculateDecimal decimal.Decimal
+
+	var audioInputQuota decimal.Decimal
+	var audioInputPrice float64
 	if !priceData.UsePrice {
-		nonCachedTokens := dPromptTokens.Sub(dCacheTokens)
-		cachedTokensWithRatio := dCacheTokens.Mul(dCacheRatio)
-
-		promptQuota := nonCachedTokens.Add(cachedTokensWithRatio)
-		if imageTokens > 0 {
-			nonImageTokens := dPromptTokens.Sub(dImageTokens)
-			imageTokensWithRatio := dImageTokens.Mul(dImageRatio)
-			promptQuota = nonImageTokens.Add(imageTokensWithRatio)
+		baseTokens := dPromptTokens
+		// 减去 cached tokens
+		var cachedTokensWithRatio decimal.Decimal
+		if !dCacheTokens.IsZero() {
+			baseTokens = baseTokens.Sub(dCacheTokens)
+			cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
+		}
+
+		// 减去 image tokens
+		var imageTokensWithRatio decimal.Decimal
+		if !dImageTokens.IsZero() {
+			baseTokens = baseTokens.Sub(dImageTokens)
+			imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
 		}
 
+		// 减去 Gemini audio tokens
+		if !dAudioTokens.IsZero() {
+			audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName)
+			if audioInputPrice > 0 {
+				// 重新计算 base tokens
+				baseTokens = baseTokens.Sub(dAudioTokens)
+				audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
+				extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())
+			}
+		}
+		promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio)
+
 		completionQuota := dCompletionTokens.Mul(dCompletionRatio)
 
 		quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
@@ -442,6 +464,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 	// 添加 responses tools call 调用的配额
 	quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
 	quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
+	// 添加 audio input 独立计费
+	quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
 
 	quota := int(quotaCalculateDecimal.Round(0).IntPart())
 	totalTokens := promptTokens + completionTokens
@@ -512,6 +536,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 			other["file_search_price"] = fileSearchPrice
 		}
 	}
+	if !audioInputQuota.IsZero() {
+		other["audio_input_seperate_price"] = true
+		other["audio_input_token_count"] = audioTokens
+		other["audio_input_price"] = audioInputPrice
+	}
 	model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
 		tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
 }

+ 17 - 0
service/audio.go

@@ -3,6 +3,7 @@ package service
 import (
 	"encoding/base64"
 	"fmt"
+	"strings"
 )
 
 func parseAudio(audioBase64 string, format string) (duration float64, err error) {
@@ -29,3 +30,19 @@ func parseAudio(audioBase64 string, format string) (duration float64, err error)
 	duration = float64(samplesCount) / float64(sampleRate)
 	return duration, nil
 }
+
+func DecodeBase64AudioData(audioBase64 string) (string, error) {
+	// 检查并移除 data:audio/xxx;base64, 前缀
+	idx := strings.Index(audioBase64, ",")
+	if idx != -1 {
+		audioBase64 = audioBase64[idx+1:]
+	}
+
+	// 解码 Base64 数据
+	_, err := base64.StdEncoding.DecodeString(audioBase64)
+	if err != nil {
+		return "", fmt.Errorf("base64 decode error: %v", err)
+	}
+
+	return audioBase64, nil
+}

+ 18 - 0
setting/operation_setting/tools.go

@@ -14,6 +14,13 @@ const (
 	FileSearchPrice = 2.5
 )
 
+const (
+	// Gemini Audio Input Price
+	Gemini25FlashPreviewInputAudioPrice     = 1.00
+	Gemini25FlashNativeAudioInputAudioPrice = 3.00
+	Gemini20FlashInputAudioPrice            = 0.70
+)
+
 func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
 	// 确定模型类型
 	// https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费
@@ -55,3 +62,14 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64
 func GetFileSearchPricePerThousand() float64 {
 	return FileSearchPrice
 }
+
+func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
+	if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
+		return Gemini25FlashPreviewInputAudioPrice
+	} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
+		return Gemini25FlashNativeAudioInputAudioPrice
+	} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
+		return Gemini20FlashInputAudioPrice
+	}
+	return 0
+}

+ 94 - 86
web/src/components/table/LogsTable.js

@@ -20,7 +20,7 @@ import {
   renderQuota,
   stringToColor,
   getLogOther,
-  renderModelTag
+  renderModelTag,
 } from '../../helpers';
 
 import {
@@ -40,15 +40,11 @@ import {
   Typography,
   Divider,
   Input,
-  DatePicker
+  DatePicker,
 } from '@douyinfe/semi-ui';
 import { ITEMS_PER_PAGE } from '../../constants';
 import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
-import {
-  IconSetting,
-  IconSearch,
-  IconForward
-} from '@douyinfe/semi-icons';
+import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons';
 
 const { Text } = Typography;
 
@@ -201,8 +197,8 @@ const LogsTable = () => {
     if (!modelMapped) {
       return renderModelTag(record.model_name, {
         onClick: (event) => {
-          copyText(event, record.model_name).then((r) => { });
-        }
+          copyText(event, record.model_name).then((r) => {});
+        },
       });
     } else {
       return (
@@ -212,20 +208,26 @@ const LogsTable = () => {
               content={
                 <div style={{ padding: 10 }}>
                   <Space vertical align={'start'}>
-                    <div className="flex items-center">
-                      <Text strong style={{ marginRight: 8 }}>{t('请求并计费模型')}:</Text>
+                    <div className='flex items-center'>
+                      <Text strong style={{ marginRight: 8 }}>
+                        {t('请求并计费模型')}:
+                      </Text>
                       {renderModelTag(record.model_name, {
                         onClick: (event) => {
-                          copyText(event, record.model_name).then((r) => { });
-                        }
+                          copyText(event, record.model_name).then((r) => {});
+                        },
                       })}
                     </div>
-                    <div className="flex items-center">
-                      <Text strong style={{ marginRight: 8 }}>{t('实际模型')}:</Text>
+                    <div className='flex items-center'>
+                      <Text strong style={{ marginRight: 8 }}>
+                        {t('实际模型')}:
+                      </Text>
                       {renderModelTag(other.upstream_model_name, {
                         onClick: (event) => {
-                          copyText(event, other.upstream_model_name).then((r) => { });
-                        }
+                          copyText(event, other.upstream_model_name).then(
+                            (r) => {},
+                          );
+                        },
                       })}
                     </div>
                   </Space>
@@ -234,9 +236,13 @@ const LogsTable = () => {
             >
               {renderModelTag(record.model_name, {
                 onClick: (event) => {
-                  copyText(event, record.model_name).then((r) => { });
+                  copyText(event, record.model_name).then((r) => {});
                 },
-                suffixIcon: <IconForward style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }} />
+                suffixIcon: (
+                  <IconForward
+                    style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
+                  />
+                ),
               })}
             </Popover>
           </Space>
@@ -597,21 +603,21 @@ const LogsTable = () => {
         }
         let content = other?.claude
           ? renderClaudeModelPriceSimple(
-            other.model_ratio,
-            other.model_price,
-            other.group_ratio,
-            other.cache_tokens || 0,
-            other.cache_ratio || 1.0,
-            other.cache_creation_tokens || 0,
-            other.cache_creation_ratio || 1.0,
-          )
+              other.model_ratio,
+              other.model_price,
+              other.group_ratio,
+              other.cache_tokens || 0,
+              other.cache_ratio || 1.0,
+              other.cache_creation_tokens || 0,
+              other.cache_creation_ratio || 1.0,
+            )
           : renderModelPriceSimple(
-            other.model_ratio,
-            other.model_price,
-            other.group_ratio,
-            other.cache_tokens || 0,
-            other.cache_ratio || 1.0,
-          );
+              other.model_ratio,
+              other.model_price,
+              other.group_ratio,
+              other.cache_tokens || 0,
+              other.cache_ratio || 1.0,
+            );
         return (
           <Paragraph
             ellipsis={{
@@ -650,25 +656,25 @@ const LogsTable = () => {
         visible={showColumnSelector}
         onCancel={() => setShowColumnSelector(false)}
         footer={
-          <div className="flex justify-end">
+          <div className='flex justify-end'>
             <Button
-              theme="light"
+              theme='light'
               onClick={() => initDefaultColumns()}
-              className="!rounded-full"
+              className='!rounded-full'
             >
               {t('重置')}
             </Button>
             <Button
-              theme="light"
+              theme='light'
               onClick={() => setShowColumnSelector(false)}
-              className="!rounded-full"
+              className='!rounded-full'
             >
               {t('取消')}
             </Button>
             <Button
               type='primary'
               onClick={() => setShowColumnSelector(false)}
-              className="!rounded-full"
+              className='!rounded-full'
             >
               {t('确定')}
             </Button>
@@ -688,7 +694,7 @@ const LogsTable = () => {
           </Checkbox>
         </div>
         <div
-          className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
+          className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
           style={{ border: '1px solid var(--semi-color-border)' }}
         >
           {allColumns.map((column) => {
@@ -703,10 +709,7 @@ const LogsTable = () => {
             }
 
             return (
-              <div
-                key={column.key}
-                className="w-1/2 mb-4 pr-2"
-              >
+              <div key={column.key} className='w-1/2 mb-4 pr-2'>
                 <Checkbox
                   checked={!!visibleColumns[column.key]}
                   onChange={(e) =>
@@ -904,27 +907,27 @@ const LogsTable = () => {
           key: t('日志详情'),
           value: other?.claude
             ? renderClaudeLogContent(
-              other?.model_ratio,
-              other.completion_ratio,
-              other.model_price,
-              other.group_ratio,
-              other.cache_ratio || 1.0,
-              other.cache_creation_ratio || 1.0,
-            )
+                other?.model_ratio,
+                other.completion_ratio,
+                other.model_price,
+                other.group_ratio,
+                other.cache_ratio || 1.0,
+                other.cache_creation_ratio || 1.0,
+              )
             : renderLogContent(
-              other?.model_ratio,
-              other.completion_ratio,
-              other.model_price,
-              other.group_ratio,
-              other?.user_group_ratio,
-              false,
-              1.0,
-              undefined,
-              other.web_search || false,
-              other.web_search_call_count || 0,
-              other.file_search || false,
-              other.file_search_call_count || 0,
-            ),
+                other?.model_ratio,
+                other.completion_ratio,
+                other.model_price,
+                other.group_ratio,
+                other?.user_group_ratio,
+                false,
+                1.0,
+                undefined,
+                other.web_search || false,
+                other.web_search_call_count || 0,
+                other.file_search || false,
+                other.file_search_call_count || 0,
+              ),
         });
       }
       if (logs[i].type === 2) {
@@ -990,6 +993,9 @@ const LogsTable = () => {
             other?.file_search || false,
             other?.file_search_call_count || 0,
             other?.file_search_price || 0,
+            other?.audio_input_seperate_price || false,
+            other?.audio_input_token_count || 0,
+            other?.audio_input_price || 0,
           );
         }
         expandDataLocal.push({
@@ -1039,7 +1045,7 @@ const LogsTable = () => {
 
   const handlePageChange = (page) => {
     setActivePage(page);
-    loadLogs(page, pageSize, logType).then((r) => { });
+    loadLogs(page, pageSize, logType).then((r) => {});
   };
 
   const handlePageSizeChange = async (size) => {
@@ -1086,16 +1092,18 @@ const LogsTable = () => {
 
   // 检查是否有任何记录有展开内容
   const hasExpandableRows = () => {
-    return logs.some(log => expandData[log.key] && expandData[log.key].length > 0);
+    return logs.some(
+      (log) => expandData[log.key] && expandData[log.key].length > 0,
+    );
   };
 
   return (
     <>
       {renderColumnSelector()}
       <Card
-        className="!rounded-2xl mb-4"
+        className='!rounded-2xl mb-4'
         title={
-          <div className="flex flex-col w-full">
+          <div className='flex flex-col w-full'>
             <Spin spinning={loadingStat}>
               <Space>
                 <Tag
@@ -1138,15 +1146,15 @@ const LogsTable = () => {
               </Space>
             </Spin>
 
-            <Divider margin="12px" />
+            <Divider margin='12px' />
 
             {/* 搜索表单区域 */}
-            <div className="flex flex-col gap-4">
-              <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+            <div className='flex flex-col gap-4'>
+              <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
                 {/* 时间选择器 */}
-                <div className="col-span-1 lg:col-span-2">
+                <div className='col-span-1 lg:col-span-2'>
                   <DatePicker
-                    className="w-full"
+                    className='w-full'
                     value={[start_timestamp, end_timestamp]}
                     type='dateTimeRange'
                     onChange={(value) => {
@@ -1162,7 +1170,7 @@ const LogsTable = () => {
                 <Select
                   value={logType.toString()}
                   placeholder={t('日志类型')}
-                  className="!rounded-full"
+                  className='!rounded-full'
                   onChange={(value) => {
                     setLogType(parseInt(value));
                     loadLogs(0, pageSize, parseInt(value));
@@ -1182,7 +1190,7 @@ const LogsTable = () => {
                   placeholder={t('令牌名称')}
                   value={token_name}
                   onChange={(value) => handleInputChange(value, 'token_name')}
-                  className="!rounded-full"
+                  className='!rounded-full'
                   showClear
                 />
 
@@ -1191,7 +1199,7 @@ const LogsTable = () => {
                   placeholder={t('模型名称')}
                   value={model_name}
                   onChange={(value) => handleInputChange(value, 'model_name')}
-                  className="!rounded-full"
+                  className='!rounded-full'
                   showClear
                 />
 
@@ -1200,7 +1208,7 @@ const LogsTable = () => {
                   placeholder={t('分组')}
                   value={group}
                   onChange={(value) => handleInputChange(value, 'group')}
-                  className="!rounded-full"
+                  className='!rounded-full'
                   showClear
                 />
 
@@ -1211,7 +1219,7 @@ const LogsTable = () => {
                       placeholder={t('渠道 ID')}
                       value={channel}
                       onChange={(value) => handleInputChange(value, 'channel')}
-                      className="!rounded-full"
+                      className='!rounded-full'
                       showClear
                     />
                     <Input
@@ -1219,7 +1227,7 @@ const LogsTable = () => {
                       placeholder={t('用户名称')}
                       value={username}
                       onChange={(value) => handleInputChange(value, 'username')}
-                      className="!rounded-full"
+                      className='!rounded-full'
                       showClear
                     />
                   </>
@@ -1227,14 +1235,14 @@ const LogsTable = () => {
               </div>
 
               {/* 操作按钮区域 */}
-              <div className="flex justify-between items-center pt-2">
+              <div className='flex justify-between items-center pt-2'>
                 <div></div>
-                <div className="flex gap-2">
+                <div className='flex gap-2'>
                   <Button
                     type='primary'
                     onClick={refresh}
                     loading={loading}
-                    className="!rounded-full"
+                    className='!rounded-full'
                   >
                     {t('查询')}
                   </Button>
@@ -1243,7 +1251,7 @@ const LogsTable = () => {
                     type='tertiary'
                     icon={<IconSetting />}
                     onClick={() => setShowColumnSelector(true)}
-                    className="!rounded-full"
+                    className='!rounded-full'
                   >
                     {t('列设置')}
                   </Button>
@@ -1259,14 +1267,14 @@ const LogsTable = () => {
           columns={getVisibleColumns()}
           {...(hasExpandableRows() && {
             expandedRowRender: expandRowRender,
-            expandRowByClick: true
+            expandRowByClick: true,
           })}
           dataSource={logs}
           rowKey='key'
           loading={loading}
           scroll={{ x: 'max-content' }}
-          className="rounded-xl overflow-hidden"
-          size="middle"
+          className='rounded-xl overflow-hidden'
+          size='middle'
           pagination={{
             formatPageText: (page) =>
               t('第 {{start}} - {{end}} 条,共 {{total}} 条', {

+ 268 - 185
web/src/helpers/render.js

@@ -44,18 +44,18 @@ import {
 
 // 侧边栏图标颜色映射
 export const sidebarIconColors = {
-  dashboard: "#4F46E5", // 紫蓝色
-  terminal: "#10B981", // 绿色
-  message: "#06B6D4", // 青色
-  key: "#3B82F6", // 蓝色
-  chart: "#8B5CF6", // 紫色
-  image: "#EC4899", // 粉色
-  check: "#F59E0B", // 琥珀色
-  credit: "#F97316", // 橙色
-  layers: "#EF4444", // 红色
-  gift: "#F43F5E", // 玫红色
-  user: "#6366F1", // 靛蓝色
-  settings: "#6B7280", // 灰色
+  dashboard: '#4F46E5', // 紫蓝色
+  terminal: '#10B981', // 绿色
+  message: '#06B6D4', // 青色
+  key: '#3B82F6', // 蓝色
+  chart: '#8B5CF6', // 紫色
+  image: '#EC4899', // 粉色
+  check: '#F59E0B', // 琥珀色
+  credit: '#F97316', // 橙色
+  layers: '#EF4444', // 红色
+  gift: '#F43F5E', // 玫红色
+  user: '#6366F1', // 靛蓝色
+  settings: '#6B7280', // 灰色
 };
 
 // 获取侧边栏Lucide图标组件
@@ -71,32 +71,97 @@ export function getLucideIcon(key, selected = false) {
   // 根据不同的key返回不同的图标
   switch (key) {
     case 'detail':
-      return <LayoutDashboard {...commonProps} color={selected ? sidebarIconColors.dashboard : 'currentColor'} />;
+      return (
+        <LayoutDashboard
+          {...commonProps}
+          color={selected ? sidebarIconColors.dashboard : 'currentColor'}
+        />
+      );
     case 'playground':
-      return <TerminalSquare {...commonProps} color={selected ? sidebarIconColors.terminal : 'currentColor'} />;
+      return (
+        <TerminalSquare
+          {...commonProps}
+          color={selected ? sidebarIconColors.terminal : 'currentColor'}
+        />
+      );
     case 'chat':
-      return <MessageSquare {...commonProps} color={selected ? sidebarIconColors.message : 'currentColor'} />;
+      return (
+        <MessageSquare
+          {...commonProps}
+          color={selected ? sidebarIconColors.message : 'currentColor'}
+        />
+      );
     case 'token':
-      return <Key {...commonProps} color={selected ? sidebarIconColors.key : 'currentColor'} />;
+      return (
+        <Key
+          {...commonProps}
+          color={selected ? sidebarIconColors.key : 'currentColor'}
+        />
+      );
     case 'log':
-      return <BarChart3 {...commonProps} color={selected ? sidebarIconColors.chart : 'currentColor'} />;
+      return (
+        <BarChart3
+          {...commonProps}
+          color={selected ? sidebarIconColors.chart : 'currentColor'}
+        />
+      );
     case 'midjourney':
-      return <ImageIcon {...commonProps} color={selected ? sidebarIconColors.image : 'currentColor'} />;
+      return (
+        <ImageIcon
+          {...commonProps}
+          color={selected ? sidebarIconColors.image : 'currentColor'}
+        />
+      );
     case 'task':
-      return <CheckSquare {...commonProps} color={selected ? sidebarIconColors.check : 'currentColor'} />;
+      return (
+        <CheckSquare
+          {...commonProps}
+          color={selected ? sidebarIconColors.check : 'currentColor'}
+        />
+      );
     case 'topup':
-      return <CreditCard {...commonProps} color={selected ? sidebarIconColors.credit : 'currentColor'} />;
+      return (
+        <CreditCard
+          {...commonProps}
+          color={selected ? sidebarIconColors.credit : 'currentColor'}
+        />
+      );
     case 'channel':
-      return <Layers {...commonProps} color={selected ? sidebarIconColors.layers : 'currentColor'} />;
+      return (
+        <Layers
+          {...commonProps}
+          color={selected ? sidebarIconColors.layers : 'currentColor'}
+        />
+      );
     case 'redemption':
-      return <Gift {...commonProps} color={selected ? sidebarIconColors.gift : 'currentColor'} />;
+      return (
+        <Gift
+          {...commonProps}
+          color={selected ? sidebarIconColors.gift : 'currentColor'}
+        />
+      );
     case 'user':
     case 'personal':
-      return <User {...commonProps} color={selected ? sidebarIconColors.user : 'currentColor'} />;
+      return (
+        <User
+          {...commonProps}
+          color={selected ? sidebarIconColors.user : 'currentColor'}
+        />
+      );
     case 'setting':
-      return <Settings {...commonProps} color={selected ? sidebarIconColors.settings : 'currentColor'} />;
+      return (
+        <Settings
+          {...commonProps}
+          color={selected ? sidebarIconColors.settings : 'currentColor'}
+        />
+      );
     default:
-      return <CircleUser {...commonProps} color={selected ? sidebarIconColors.user : 'currentColor'} />;
+      return (
+        <CircleUser
+          {...commonProps}
+          color={selected ? sidebarIconColors.user : 'currentColor'}
+        />
+      );
   }
 }
 
@@ -115,12 +180,13 @@ export const getModelCategories = (() => {
       all: {
         label: t('全部模型'),
         icon: null,
-        filter: () => true
+        filter: () => true,
       },
       openai: {
         label: 'OpenAI',
         icon: <OpenAI />,
-        filter: (model) => model.model_name.toLowerCase().includes('gpt') ||
+        filter: (model) =>
+          model.model_name.toLowerCase().includes('gpt') ||
           model.model_name.toLowerCase().includes('dall-e') ||
           model.model_name.toLowerCase().includes('whisper') ||
           model.model_name.toLowerCase().includes('tts') ||
@@ -131,109 +197,110 @@ export const getModelCategories = (() => {
           model.model_name.toLowerCase().includes('ada') ||
           model.model_name.toLowerCase().includes('o1') ||
           model.model_name.toLowerCase().includes('o3') ||
-          model.model_name.toLowerCase().includes('o4')
+          model.model_name.toLowerCase().includes('o4'),
       },
       anthropic: {
         label: 'Anthropic',
         icon: <Claude.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('claude')
+        filter: (model) => model.model_name.toLowerCase().includes('claude'),
       },
       gemini: {
         label: 'Gemini',
         icon: <Gemini.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('gemini')
+        filter: (model) => model.model_name.toLowerCase().includes('gemini'),
       },
       moonshot: {
         label: 'Moonshot',
         icon: <Moonshot />,
-        filter: (model) => model.model_name.toLowerCase().includes('moonshot')
+        filter: (model) => model.model_name.toLowerCase().includes('moonshot'),
       },
       zhipu: {
         label: t('智谱'),
         icon: <Zhipu.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('chatglm') ||
-          model.model_name.toLowerCase().includes('glm-')
+        filter: (model) =>
+          model.model_name.toLowerCase().includes('chatglm') ||
+          model.model_name.toLowerCase().includes('glm-'),
       },
       qwen: {
         label: t('通义千问'),
         icon: <Qwen.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('qwen')
+        filter: (model) => model.model_name.toLowerCase().includes('qwen'),
       },
       deepseek: {
         label: 'DeepSeek',
         icon: <DeepSeek.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('deepseek')
+        filter: (model) => model.model_name.toLowerCase().includes('deepseek'),
       },
       minimax: {
         label: 'MiniMax',
         icon: <Minimax.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('abab')
+        filter: (model) => model.model_name.toLowerCase().includes('abab'),
       },
       baidu: {
         label: t('文心一言'),
         icon: <Wenxin.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('ernie')
+        filter: (model) => model.model_name.toLowerCase().includes('ernie'),
       },
       xunfei: {
         label: t('讯飞星火'),
         icon: <Spark.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('spark')
+        filter: (model) => model.model_name.toLowerCase().includes('spark'),
       },
       midjourney: {
         label: 'Midjourney',
         icon: <Midjourney />,
-        filter: (model) => model.model_name.toLowerCase().includes('mj_')
+        filter: (model) => model.model_name.toLowerCase().includes('mj_'),
       },
       tencent: {
         label: t('腾讯混元'),
         icon: <Hunyuan.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('hunyuan')
+        filter: (model) => model.model_name.toLowerCase().includes('hunyuan'),
       },
       cohere: {
         label: 'Cohere',
         icon: <Cohere.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('command')
+        filter: (model) => model.model_name.toLowerCase().includes('command'),
       },
       cloudflare: {
         label: 'Cloudflare',
         icon: <Cloudflare.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('@cf/')
+        filter: (model) => model.model_name.toLowerCase().includes('@cf/'),
       },
       ai360: {
         label: t('360智脑'),
         icon: <Ai360.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('360')
+        filter: (model) => model.model_name.toLowerCase().includes('360'),
       },
       yi: {
         label: t('零一万物'),
         icon: <Yi.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('yi')
+        filter: (model) => model.model_name.toLowerCase().includes('yi'),
       },
       jina: {
         label: 'Jina',
         icon: <Jina />,
-        filter: (model) => model.model_name.toLowerCase().includes('jina')
+        filter: (model) => model.model_name.toLowerCase().includes('jina'),
       },
       mistral: {
         label: 'Mistral AI',
         icon: <Mistral.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('mistral')
+        filter: (model) => model.model_name.toLowerCase().includes('mistral'),
       },
       xai: {
         label: 'xAI',
         icon: <XAI />,
-        filter: (model) => model.model_name.toLowerCase().includes('grok')
+        filter: (model) => model.model_name.toLowerCase().includes('grok'),
       },
       llama: {
         label: 'Llama',
         icon: <Ollama />,
-        filter: (model) => model.model_name.toLowerCase().includes('llama')
+        filter: (model) => model.model_name.toLowerCase().includes('llama'),
       },
       doubao: {
         label: t('豆包'),
         icon: <Doubao.Color />,
-        filter: (model) => model.model_name.toLowerCase().includes('doubao')
-      }
+        filter: (model) => model.model_name.toLowerCase().includes('doubao'),
+      },
     };
 
     lastLocale = currentLocale;
@@ -376,7 +443,13 @@ export function stringToColor(str) {
 
 // 渲染带有模型图标的标签
 export function renderModelTag(modelName, options = {}) {
-  const { color, size = 'large', shape = 'circle', onClick, suffixIcon } = options;
+  const {
+    color,
+    size = 'large',
+    shape = 'circle',
+    onClick,
+    suffixIcon,
+  } = options;
 
   const categories = getModelCategories(i18next.t);
   let icon = null;
@@ -724,6 +797,9 @@ export function renderModelPrice(
   fileSearch = false,
   fileSearchCallCount = 0,
   fileSearchPrice = 0,
+  audioInputSeperatePrice = false,
+  audioInputTokens = 0,
+  audioInputPrice = 0,
 ) {
   if (modelPrice !== -1) {
     return i18next.t(
@@ -751,9 +827,12 @@ export function renderModelPrice(
       effectiveInputTokens =
         inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
     }
-
+    if (audioInputTokens > 0) {
+      effectiveInputTokens -= audioInputTokens;
+    }
     let price =
       (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
+      (audioInputTokens / 1000000) * audioInputPrice * groupRatio +
       (completionTokens / 1000000) * completionRatioPrice * groupRatio +
       (webSearchCallCount / 1000) * webSearchPrice * groupRatio +
       (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
@@ -762,8 +841,11 @@ export function renderModelPrice(
       <>
         <article>
           <p>
-            {i18next.t('输入价格:${{price}} / 1M tokens', {
+            {i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
               price: inputRatioPrice,
+              audioPrice: audioInputSeperatePrice
+                ? `,音频 $${audioInputPrice} / 1M tokens`
+                : '',
             })}
           </p>
           <p>
@@ -817,96 +899,93 @@ export function renderModelPrice(
           )}
           <p></p>
           <p>
-            {cacheTokens > 0 && !image && !webSearch && !fileSearch
-              ? i18next.t(
-                '输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                {
-                  nonCacheInput: inputTokens - cacheTokens,
-                  cacheInput: cacheTokens,
-                  cachePrice: inputRatioPrice * cacheRatio,
-                  price: inputRatioPrice,
-                  completion: completionTokens,
-                  compPrice: completionRatioPrice,
-                  ratio: groupRatio,
-                  total: price.toFixed(6),
-                },
-              )
-              : image && imageOutputTokens > 0 && !webSearch && !fileSearch
-                ? i18next.t(
-                  '输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+            {(() => {
+              // 构建输入部分描述
+              let inputDesc = '';
+              if (image && imageOutputTokens > 0) {
+                inputDesc = i18next.t(
+                  '(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
                   {
                     nonImageInput: inputTokens - imageOutputTokens,
                     imageInput: imageOutputTokens,
                     imageRatio: imageRatio,
                     price: inputRatioPrice,
-                    completion: completionTokens,
-                    compPrice: completionRatioPrice,
-                    ratio: groupRatio,
-                    total: price.toFixed(6),
                   },
-                )
-                : webSearch && webSearchCallCount > 0 && !image && !fileSearch
+                );
+              } else if (cacheTokens > 0) {
+                inputDesc = i18next.t(
+                  '(输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}}',
+                  {
+                    nonCacheInput: inputTokens - cacheTokens,
+                    cacheInput: cacheTokens,
+                    price: inputRatioPrice,
+                    cachePrice: cacheRatioPrice,
+                  },
+                );
+              } else if (audioInputSeperatePrice && audioInputTokens > 0) {
+                inputDesc = i18next.t(
+                  '(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}',
+                  {
+                    nonAudioInput: inputTokens - audioInputTokens,
+                    audioInput: audioInputTokens,
+                    price: inputRatioPrice,
+                    audioPrice: audioInputPrice,
+                  },
+                );
+              } else {
+                inputDesc = i18next.t(
+                  '(输入 {{input}} tokens / 1M tokens * ${{price}}',
+                  {
+                    input: inputTokens,
+                    price: inputRatioPrice,
+                  },
+                );
+              }
+
+              // 构建输出部分描述
+              const outputDesc = i18next.t(
+                '输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * 分组倍率 {{ratio}}',
+                {
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  ratio: groupRatio,
+                },
+              );
+
+              // 构建额外服务描述
+              const extraServices = [
+                webSearch && webSearchCallCount > 0
                   ? i18next.t(
-                    '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}} = ${{total}}',
-                    {
-                      input: inputTokens,
-                      price: inputRatioPrice,
-                      completion: completionTokens,
-                      compPrice: completionRatioPrice,
-                      ratio: groupRatio,
-                      webSearchCallCount,
-                      webSearchPrice,
-                      total: price.toFixed(6),
-                    },
-                  )
-                  : fileSearch &&
-                    fileSearchCallCount > 0 &&
-                    !image &&
-                    !webSearch
-                    ? i18next.t(
-                      '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
+                      ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
                       {
-                        input: inputTokens,
-                        price: inputRatioPrice,
-                        completion: completionTokens,
-                        compPrice: completionRatioPrice,
+                        count: webSearchCallCount,
+                        price: webSearchPrice,
                         ratio: groupRatio,
-                        fileSearchCallCount,
-                        fileSearchPrice,
-                        total: price.toFixed(6),
                       },
                     )
-                    : webSearch &&
-                      webSearchCallCount > 0 &&
-                      fileSearch &&
-                      fileSearchCallCount > 0 &&
-                      !image
-                      ? i18next.t(
-                        '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}}+ 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
-                        {
-                          input: inputTokens,
-                          price: inputRatioPrice,
-                          completion: completionTokens,
-                          compPrice: completionRatioPrice,
-                          ratio: groupRatio,
-                          webSearchCallCount,
-                          webSearchPrice,
-                          fileSearchCallCount,
-                          fileSearchPrice,
-                          total: price.toFixed(6),
-                        },
-                      )
-                      : i18next.t(
-                        '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                        {
-                          input: inputTokens,
-                          price: inputRatioPrice,
-                          completion: completionTokens,
-                          compPrice: completionRatioPrice,
-                          ratio: groupRatio,
-                          total: price.toFixed(6),
-                        },
-                      )}
+                  : '',
+                fileSearch && fileSearchCallCount > 0
+                  ? i18next.t(
+                      ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
+                      {
+                        count: fileSearchCallCount,
+                        price: fileSearchPrice,
+                        ratio: groupRatio,
+                      },
+                    )
+                  : '',
+              ].join('');
+
+              return i18next.t(
+                '{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
+                {
+                  inputDesc,
+                  outputDesc,
+                  extraServices,
+                  total: price.toFixed(6),
+                },
+              );
+            })()}
           </p>
           <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
         </article>
@@ -1077,10 +1156,10 @@ export function renderAudioModelPrice(
     let audioPrice =
       (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
       (audioCompletionTokens / 1000000) *
-      inputRatioPrice *
-      audioRatio *
-      audioCompletionRatio *
-      groupRatio;
+        inputRatioPrice *
+        audioRatio *
+        audioCompletionRatio *
+        groupRatio;
     let price = textPrice + audioPrice;
     return (
       <>
@@ -1136,27 +1215,27 @@ export function renderAudioModelPrice(
           <p>
             {cacheTokens > 0
               ? i18next.t(
-                '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
-                {
-                  nonCacheInput: inputTokens - cacheTokens,
-                  cacheInput: cacheTokens,
-                  cachePrice: inputRatioPrice * cacheRatio,
-                  price: inputRatioPrice,
-                  completion: completionTokens,
-                  compPrice: completionRatioPrice,
-                  total: textPrice.toFixed(6),
-                },
-              )
+                  '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+                  {
+                    nonCacheInput: inputTokens - cacheTokens,
+                    cacheInput: cacheTokens,
+                    cachePrice: inputRatioPrice * cacheRatio,
+                    price: inputRatioPrice,
+                    completion: completionTokens,
+                    compPrice: completionRatioPrice,
+                    total: textPrice.toFixed(6),
+                  },
+                )
               : i18next.t(
-                '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
-                {
-                  input: inputTokens,
-                  price: inputRatioPrice,
-                  completion: completionTokens,
-                  compPrice: completionRatioPrice,
-                  total: textPrice.toFixed(6),
-                },
-              )}
+                  '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+                  {
+                    input: inputTokens,
+                    price: inputRatioPrice,
+                    completion: completionTokens,
+                    compPrice: completionRatioPrice,
+                    total: textPrice.toFixed(6),
+                  },
+                )}
           </p>
           <p>
             {i18next.t(
@@ -1293,33 +1372,33 @@ export function renderClaudeModelPrice(
           <p>
             {cacheTokens > 0 || cacheCreationTokens > 0
               ? i18next.t(
-                '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                {
-                  nonCacheInput: nonCachedTokens,
-                  cacheInput: cacheTokens,
-                  cacheRatio: cacheRatio,
-                  cacheCreationInput: cacheCreationTokens,
-                  cacheCreationRatio: cacheCreationRatio,
-                  cachePrice: cacheRatioPrice,
-                  cacheCreationPrice: cacheCreationRatioPrice,
-                  price: inputRatioPrice,
-                  completion: completionTokens,
-                  compPrice: completionRatioPrice,
-                  ratio: groupRatio,
-                  total: price.toFixed(6),
-                },
-              )
+                  '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                  {
+                    nonCacheInput: nonCachedTokens,
+                    cacheInput: cacheTokens,
+                    cacheRatio: cacheRatio,
+                    cacheCreationInput: cacheCreationTokens,
+                    cacheCreationRatio: cacheCreationRatio,
+                    cachePrice: cacheRatioPrice,
+                    cacheCreationPrice: cacheCreationRatioPrice,
+                    price: inputRatioPrice,
+                    completion: completionTokens,
+                    compPrice: completionRatioPrice,
+                    ratio: groupRatio,
+                    total: price.toFixed(6),
+                  },
+                )
               : i18next.t(
-                '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
-                {
-                  input: inputTokens,
-                  price: inputRatioPrice,
-                  completion: completionTokens,
-                  compPrice: completionRatioPrice,
-                  ratio: groupRatio,
-                  total: price.toFixed(6),
-                },
-              )}
+                  '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                  {
+                    input: inputTokens,
+                    price: inputRatioPrice,
+                    completion: completionTokens,
+                    compPrice: completionRatioPrice,
+                    ratio: groupRatio,
+                    total: price.toFixed(6),
+                  },
+                )}
           </p>
           <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
         </article>
@@ -1410,7 +1489,9 @@ export function rehypeSplitWordsIntoSpans(options = {}) {
 
     visit(tree, 'element', (node) => {
       if (
-        ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(node.tagName) &&
+        ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(
+          node.tagName,
+        ) &&
         node.children
       ) {
         const newChildren = [];
@@ -1418,7 +1499,9 @@ export function rehypeSplitWordsIntoSpans(options = {}) {
           if (child.type === 'text') {
             try {
               // 使用 Intl.Segmenter 精准拆分中英文及标点
-              const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
+              const segmenter = new Intl.Segmenter('zh', {
+                granularity: 'word',
+              });
               const segments = segmenter.segment(child.value);
 
               Array.from(segments)
@@ -1472,4 +1555,4 @@ export function rehypeSplitWordsIntoSpans(options = {}) {
       }
     });
   };
-} 
+}