From 9496dac448ce19fcc84417c57ff0c80511b5efd3 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Sat, 7 Jun 2025 12:26:23 +0800 Subject: [PATCH] feat: gemini audio input billing --- relay/channel/gemini/dto.go | 14 +- relay/channel/gemini/relay-gemini-native.go | 19 +- relay/channel/gemini/relay-gemini.go | 19 +- relay/relay-text.go | 47 ++- service/audio.go | 17 + setting/operation_setting/tools.go | 18 ++ web/src/components/table/LogsTable.js | 174 +++++----- web/src/helpers/render.js | 338 +++++++++++--------- 8 files changed, 386 insertions(+), 260 deletions(-) diff --git a/relay/channel/gemini/dto.go b/relay/channel/gemini/dto.go index b1dffe90..6403ffbc 100644 --- a/relay/channel/gemini/dto.go +++ b/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 diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 8e2eae04..d9d0054d 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/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的行为 diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 303eca6b..4022c9b0 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/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 { diff --git a/relay/relay-text.go b/relay/relay-text.go index f1105907..a48a664a 100644 --- a/relay/relay-text.go +++ b/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 - 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) + var audioInputQuota decimal.Decimal + var audioInputPrice float64 + if !priceData.UsePrice { + 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) } diff --git a/service/audio.go b/service/audio.go index d558e96f..c4b6f01b 100644 --- a/service/audio.go +++ b/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 +} diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index 974c4ed2..3e1af99e 100644 --- a/setting/operation_setting/tools.go +++ b/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 +} diff --git a/web/src/components/table/LogsTable.js b/web/src/components/table/LogsTable.js index 50c02c2b..858cf784 100644 --- a/web/src/components/table/LogsTable.js +++ b/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={
-
- {t('请求并计费模型')}: +
+ + {t('请求并计费模型')}: + {renderModelTag(record.model_name, { onClick: (event) => { - copyText(event, record.model_name).then((r) => { }); - } + copyText(event, record.model_name).then((r) => {}); + }, })}
-
- {t('实际模型')}: +
+ + {t('实际模型')}: + {renderModelTag(other.upstream_model_name, { onClick: (event) => { - copyText(event, other.upstream_model_name).then((r) => { }); - } + copyText(event, other.upstream_model_name).then( + (r) => {}, + ); + }, })}
@@ -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: + suffixIcon: ( + + ), })} @@ -609,21 +615,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 ( { visible={showColumnSelector} onCancel={() => setShowColumnSelector(false)} footer={ -
+
@@ -700,7 +706,7 @@ const LogsTable = () => {
{allColumns.map((column) => { @@ -715,10 +721,7 @@ const LogsTable = () => { } return ( -
+
@@ -916,27 +919,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) { @@ -1002,6 +1005,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({ @@ -1051,7 +1057,7 @@ const LogsTable = () => { const handlePageChange = (page) => { setActivePage(page); - loadLogs(page, pageSize, logType).then((r) => { }); + loadLogs(page, pageSize, logType).then((r) => {}); }; const handlePageSizeChange = async (size) => { @@ -1100,9 +1106,9 @@ const LogsTable = () => { <> {renderColumnSelector()} +
{ - + {/* 搜索表单区域 */} -
-
+
+
{/* 时间选择器 */} -
+
{ @@ -1169,7 +1175,7 @@ const LogsTable = () => { { placeholder={t('用户名称')} value={username} onChange={(value) => handleInputChange(value, 'username')} - className="!rounded-full" + className='!rounded-full' showClear /> @@ -1234,14 +1240,14 @@ const LogsTable = () => {
{/* 操作按钮区域 */} -
+
-
+
@@ -1250,7 +1256,7 @@ const LogsTable = () => { type='tertiary' icon={} onClick={() => setShowColumnSelector(true)} - className="!rounded-full" + className='!rounded-full' > {t('列设置')} @@ -1268,8 +1274,8 @@ const LogsTable = () => { dataSource={logs} rowKey='key' loading={loading} - className="rounded-xl overflow-hidden" - size="middle" + className='rounded-xl overflow-hidden' + size='middle' pagination={{ formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', { diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index b548217c..d2cc7fe1 100644 --- a/web/src/helpers/render.js +++ b/web/src/helpers/render.js @@ -41,12 +41,13 @@ export const getModelCategories = (() => { all: { label: t('全部模型'), icon: null, - filter: () => true + filter: () => true, }, openai: { label: 'OpenAI', icon: , - 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') || @@ -54,109 +55,110 @@ export const getModelCategories = (() => { model.model_name.toLowerCase().includes('babbage') || model.model_name.toLowerCase().includes('davinci') || model.model_name.toLowerCase().includes('curie') || - model.model_name.toLowerCase().includes('ada') + model.model_name.toLowerCase().includes('ada'), }, anthropic: { label: 'Anthropic', icon: , - filter: (model) => model.model_name.toLowerCase().includes('claude') + filter: (model) => model.model_name.toLowerCase().includes('claude'), }, gemini: { label: 'Gemini', icon: , - filter: (model) => model.model_name.toLowerCase().includes('gemini') + filter: (model) => model.model_name.toLowerCase().includes('gemini'), }, moonshot: { label: 'Moonshot', icon: , - filter: (model) => model.model_name.toLowerCase().includes('moonshot') + filter: (model) => model.model_name.toLowerCase().includes('moonshot'), }, zhipu: { label: t('智谱'), icon: , - 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: , - filter: (model) => model.model_name.toLowerCase().includes('qwen') + filter: (model) => model.model_name.toLowerCase().includes('qwen'), }, deepseek: { label: 'DeepSeek', icon: , - filter: (model) => model.model_name.toLowerCase().includes('deepseek') + filter: (model) => model.model_name.toLowerCase().includes('deepseek'), }, minimax: { label: 'MiniMax', icon: , - filter: (model) => model.model_name.toLowerCase().includes('abab') + filter: (model) => model.model_name.toLowerCase().includes('abab'), }, baidu: { label: t('文心一言'), icon: , - filter: (model) => model.model_name.toLowerCase().includes('ernie') + filter: (model) => model.model_name.toLowerCase().includes('ernie'), }, xunfei: { label: t('讯飞星火'), icon: , - filter: (model) => model.model_name.toLowerCase().includes('spark') + filter: (model) => model.model_name.toLowerCase().includes('spark'), }, midjourney: { label: 'Midjourney', icon: , - filter: (model) => model.model_name.toLowerCase().includes('mj_') + filter: (model) => model.model_name.toLowerCase().includes('mj_'), }, tencent: { label: t('腾讯混元'), icon: , - filter: (model) => model.model_name.toLowerCase().includes('hunyuan') + filter: (model) => model.model_name.toLowerCase().includes('hunyuan'), }, cohere: { label: 'Cohere', icon: , - filter: (model) => model.model_name.toLowerCase().includes('command') + filter: (model) => model.model_name.toLowerCase().includes('command'), }, cloudflare: { label: 'Cloudflare', icon: , - filter: (model) => model.model_name.toLowerCase().includes('@cf/') + filter: (model) => model.model_name.toLowerCase().includes('@cf/'), }, ai360: { label: t('360智脑'), icon: , - filter: (model) => model.model_name.toLowerCase().includes('360') + filter: (model) => model.model_name.toLowerCase().includes('360'), }, yi: { label: t('零一万物'), icon: , - filter: (model) => model.model_name.toLowerCase().includes('yi') + filter: (model) => model.model_name.toLowerCase().includes('yi'), }, jina: { label: 'Jina', icon: , - filter: (model) => model.model_name.toLowerCase().includes('jina') + filter: (model) => model.model_name.toLowerCase().includes('jina'), }, mistral: { label: 'Mistral AI', icon: , - filter: (model) => model.model_name.toLowerCase().includes('mistral') + filter: (model) => model.model_name.toLowerCase().includes('mistral'), }, xai: { label: 'xAI', icon: , - filter: (model) => model.model_name.toLowerCase().includes('grok') + filter: (model) => model.model_name.toLowerCase().includes('grok'), }, llama: { label: 'Llama', icon: , - filter: (model) => model.model_name.toLowerCase().includes('llama') + filter: (model) => model.model_name.toLowerCase().includes('llama'), }, doubao: { label: t('豆包'), icon: , - filter: (model) => model.model_name.toLowerCase().includes('doubao') - } + filter: (model) => model.model_name.toLowerCase().includes('doubao'), + }, }; lastLocale = currentLocale; @@ -299,7 +301,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; @@ -647,6 +655,9 @@ export function renderModelPrice( fileSearch = false, fileSearchCallCount = 0, fileSearchPrice = 0, + audioInputSeperatePrice = false, + audioInputTokens = 0, + audioInputPrice = 0, ) { if (modelPrice !== -1) { return i18next.t( @@ -674,9 +685,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; @@ -685,8 +699,11 @@ export function renderModelPrice( <>

- {i18next.t('输入价格:${{price}} / 1M tokens', { + {i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', { price: inputRatioPrice, + audioPrice: audioInputSeperatePrice + ? `,音频 $${audioInputPrice} / 1M tokens` + : '', })}

@@ -740,96 +757,93 @@ export function renderModelPrice( )}

- {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), + }, + ); + })()}

{i18next.t('仅供参考,以实际扣费为准')}

@@ -1000,10 +1014,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 ( <> @@ -1059,27 +1073,27 @@ export function renderAudioModelPrice(

{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), + }, + )}

{i18next.t( @@ -1216,33 +1230,33 @@ export function renderClaudeModelPrice(

{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), + }, + )}

{i18next.t('仅供参考,以实际扣费为准')}

@@ -1333,7 +1347,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 = []; @@ -1341,7 +1357,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) @@ -1395,4 +1413,4 @@ export function rehypeSplitWordsIntoSpans(options = {}) { } }); }; -} \ No newline at end of file +}