feat: gemini audio input billing

This commit is contained in:
creamlike1024
2025-06-07 12:26:23 +08:00
parent f17b4f0760
commit 9496dac448
8 changed files with 386 additions and 260 deletions

View File

@@ -116,6 +116,12 @@ type GeminiUsageMetadata struct {
CandidatesTokenCount int `json:"candidatesTokenCount"` CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"` TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"` ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
}
type GeminiPromptTokensDetails struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
} }
// Imagen related structs // Imagen related structs

View File

@@ -55,6 +55,16 @@ func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *rela
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount, 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 响应 // 直接返回 Gemini 原生格式的 JSON 响应
jsonResponse, err := json.Marshal(geminiResponse) jsonResponse, err := json.Marshal(geminiResponse)
if err != nil { if err != nil {
@@ -100,6 +110,14 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount 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 响应 // 直接发送 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 usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
// 移除流式响应结尾的[Done]因为Gemini API没有发送Done的行为 // 移除流式响应结尾的[Done]因为Gemini API没有发送Done的行为

View File

@@ -313,13 +313,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
if part.GetInputAudio().Data == "" { if part.GetInputAudio().Data == "" {
return nil, fmt.Errorf("only base64 audio is supported in gemini") 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 { if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error()) return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
} }
parts = append(parts, GeminiPart{ parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{ InlineData: &GeminiInlineData{
MimeType: format, MimeType: "audio/" + part.GetInputAudio().Format,
Data: base64String, Data: base64String,
}, },
}) })
@@ -771,6 +771,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount 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) err = helper.ObjectData(c, response)
if err != nil { 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.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens 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 fullTextResponse.Usage = usage
jsonResponse, err := json.Marshal(fullTextResponse) jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil { if err != nil {

View File

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

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"strings"
) )
func parseAudio(audioBase64 string, format string) (duration float64, err error) { 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) duration = float64(samplesCount) / float64(sampleRate)
return duration, nil 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
}

View File

@@ -14,6 +14,13 @@ const (
FileSearchPrice = 2.5 FileSearchPrice = 2.5
) )
const (
// Gemini Audio Input Price
Gemini25FlashPreviewInputAudioPrice = 1.00
Gemini25FlashNativeAudioInputAudioPrice = 3.00
Gemini20FlashInputAudioPrice = 0.70
)
func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 { func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
// 确定模型类型 // 确定模型类型
// https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费 // 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 { func GetFileSearchPricePerThousand() float64 {
return FileSearchPrice 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
}

View File

@@ -20,7 +20,7 @@ import {
renderQuota, renderQuota,
stringToColor, stringToColor,
getLogOther, getLogOther,
renderModelTag renderModelTag,
} from '../../helpers'; } from '../../helpers';
import { import {
@@ -40,15 +40,11 @@ import {
Typography, Typography,
Divider, Divider,
Input, Input,
DatePicker DatePicker,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph'; import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
import { import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons';
IconSetting,
IconSearch,
IconForward
} from '@douyinfe/semi-icons';
const { Text } = Typography; const { Text } = Typography;
@@ -201,8 +197,8 @@ const LogsTable = () => {
if (!modelMapped) { if (!modelMapped) {
return renderModelTag(record.model_name, { return renderModelTag(record.model_name, {
onClick: (event) => { onClick: (event) => {
copyText(event, record.model_name).then((r) => { }); copyText(event, record.model_name).then((r) => {});
} },
}); });
} else { } else {
return ( return (
@@ -212,20 +208,26 @@ const LogsTable = () => {
content={ content={
<div style={{ padding: 10 }}> <div style={{ padding: 10 }}>
<Space vertical align={'start'}> <Space vertical align={'start'}>
<div className="flex items-center"> <div className='flex items-center'>
<Text strong style={{ marginRight: 8 }}>{t('请求并计费模型')}:</Text> <Text strong style={{ marginRight: 8 }}>
{t('请求并计费模型')}:
</Text>
{renderModelTag(record.model_name, { {renderModelTag(record.model_name, {
onClick: (event) => { onClick: (event) => {
copyText(event, record.model_name).then((r) => { }); copyText(event, record.model_name).then((r) => {});
} },
})} })}
</div> </div>
<div className="flex items-center"> <div className='flex items-center'>
<Text strong style={{ marginRight: 8 }}>{t('实际模型')}:</Text> <Text strong style={{ marginRight: 8 }}>
{t('实际模型')}:
</Text>
{renderModelTag(other.upstream_model_name, { {renderModelTag(other.upstream_model_name, {
onClick: (event) => { onClick: (event) => {
copyText(event, other.upstream_model_name).then((r) => { }); copyText(event, other.upstream_model_name).then(
} (r) => {},
);
},
})} })}
</div> </div>
</Space> </Space>
@@ -234,9 +236,13 @@ const LogsTable = () => {
> >
{renderModelTag(record.model_name, { {renderModelTag(record.model_name, {
onClick: (event) => { 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> </Popover>
</Space> </Space>
@@ -662,25 +668,25 @@ const LogsTable = () => {
visible={showColumnSelector} visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)} onCancel={() => setShowColumnSelector(false)}
footer={ footer={
<div className="flex justify-end"> <div className='flex justify-end'>
<Button <Button
theme="light" theme='light'
onClick={() => initDefaultColumns()} onClick={() => initDefaultColumns()}
className="!rounded-full" className='!rounded-full'
> >
{t('重置')} {t('重置')}
</Button> </Button>
<Button <Button
theme="light" theme='light'
onClick={() => setShowColumnSelector(false)} onClick={() => setShowColumnSelector(false)}
className="!rounded-full" className='!rounded-full'
> >
{t('取消')} {t('取消')}
</Button> </Button>
<Button <Button
type='primary' type='primary'
onClick={() => setShowColumnSelector(false)} onClick={() => setShowColumnSelector(false)}
className="!rounded-full" className='!rounded-full'
> >
{t('确定')} {t('确定')}
</Button> </Button>
@@ -700,7 +706,7 @@ const LogsTable = () => {
</Checkbox> </Checkbox>
</div> </div>
<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)' }} style={{ border: '1px solid var(--semi-color-border)' }}
> >
{allColumns.map((column) => { {allColumns.map((column) => {
@@ -715,10 +721,7 @@ const LogsTable = () => {
} }
return ( return (
<div <div key={column.key} className='w-1/2 mb-4 pr-2'>
key={column.key}
className="w-1/2 mb-4 pr-2"
>
<Checkbox <Checkbox
checked={!!visibleColumns[column.key]} checked={!!visibleColumns[column.key]}
onChange={(e) => onChange={(e) =>
@@ -1002,6 +1005,9 @@ const LogsTable = () => {
other?.file_search || false, other?.file_search || false,
other?.file_search_call_count || 0, other?.file_search_call_count || 0,
other?.file_search_price || 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({ expandDataLocal.push({
@@ -1051,7 +1057,7 @@ const LogsTable = () => {
const handlePageChange = (page) => { const handlePageChange = (page) => {
setActivePage(page); setActivePage(page);
loadLogs(page, pageSize, logType).then((r) => { }); loadLogs(page, pageSize, logType).then((r) => {});
}; };
const handlePageSizeChange = async (size) => { const handlePageSizeChange = async (size) => {
@@ -1100,9 +1106,9 @@ const LogsTable = () => {
<> <>
{renderColumnSelector()} {renderColumnSelector()}
<Card <Card
className="!rounded-2xl overflow-hidden mb-4" className='!rounded-2xl overflow-hidden mb-4'
title={ title={
<div className="flex flex-col w-full"> <div className='flex flex-col w-full'>
<Spin spinning={loadingStat}> <Spin spinning={loadingStat}>
<Space> <Space>
<Tag <Tag
@@ -1145,15 +1151,15 @@ const LogsTable = () => {
</Space> </Space>
</Spin> </Spin>
<Divider margin="12px" /> <Divider margin='12px' />
{/* 搜索表单区域 */} {/* 搜索表单区域 */}
<div className="flex flex-col 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='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 <DatePicker
className="w-full" className='w-full'
value={[start_timestamp, end_timestamp]} value={[start_timestamp, end_timestamp]}
type='dateTimeRange' type='dateTimeRange'
onChange={(value) => { onChange={(value) => {
@@ -1169,7 +1175,7 @@ const LogsTable = () => {
<Select <Select
value={logType.toString()} value={logType.toString()}
placeholder={t('日志类型')} placeholder={t('日志类型')}
className="!rounded-full" className='!rounded-full'
onChange={(value) => { onChange={(value) => {
setLogType(parseInt(value)); setLogType(parseInt(value));
loadLogs(0, pageSize, parseInt(value)); loadLogs(0, pageSize, parseInt(value));
@@ -1189,7 +1195,7 @@ const LogsTable = () => {
placeholder={t('令牌名称')} placeholder={t('令牌名称')}
value={token_name} value={token_name}
onChange={(value) => handleInputChange(value, 'token_name')} onChange={(value) => handleInputChange(value, 'token_name')}
className="!rounded-full" className='!rounded-full'
showClear showClear
/> />
@@ -1198,7 +1204,7 @@ const LogsTable = () => {
placeholder={t('模型名称')} placeholder={t('模型名称')}
value={model_name} value={model_name}
onChange={(value) => handleInputChange(value, 'model_name')} onChange={(value) => handleInputChange(value, 'model_name')}
className="!rounded-full" className='!rounded-full'
showClear showClear
/> />
@@ -1207,7 +1213,7 @@ const LogsTable = () => {
placeholder={t('分组')} placeholder={t('分组')}
value={group} value={group}
onChange={(value) => handleInputChange(value, 'group')} onChange={(value) => handleInputChange(value, 'group')}
className="!rounded-full" className='!rounded-full'
showClear showClear
/> />
@@ -1218,7 +1224,7 @@ const LogsTable = () => {
placeholder={t('渠道 ID')} placeholder={t('渠道 ID')}
value={channel} value={channel}
onChange={(value) => handleInputChange(value, 'channel')} onChange={(value) => handleInputChange(value, 'channel')}
className="!rounded-full" className='!rounded-full'
showClear showClear
/> />
<Input <Input
@@ -1226,7 +1232,7 @@ const LogsTable = () => {
placeholder={t('用户名称')} placeholder={t('用户名称')}
value={username} value={username}
onChange={(value) => handleInputChange(value, 'username')} onChange={(value) => handleInputChange(value, 'username')}
className="!rounded-full" className='!rounded-full'
showClear showClear
/> />
</> </>
@@ -1234,14 +1240,14 @@ const LogsTable = () => {
</div> </div>
{/* 操作按钮区域 */} {/* 操作按钮区域 */}
<div className="flex justify-between items-center pt-2"> <div className='flex justify-between items-center pt-2'>
<div></div> <div></div>
<div className="flex gap-2"> <div className='flex gap-2'>
<Button <Button
type='primary' type='primary'
onClick={refresh} onClick={refresh}
loading={loading} loading={loading}
className="!rounded-full" className='!rounded-full'
> >
{t('查询')} {t('查询')}
</Button> </Button>
@@ -1250,7 +1256,7 @@ const LogsTable = () => {
type='tertiary' type='tertiary'
icon={<IconSetting />} icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)} onClick={() => setShowColumnSelector(true)}
className="!rounded-full" className='!rounded-full'
> >
{t('列设置')} {t('列设置')}
</Button> </Button>
@@ -1268,8 +1274,8 @@ const LogsTable = () => {
dataSource={logs} dataSource={logs}
rowKey='key' rowKey='key'
loading={loading} loading={loading}
className="rounded-xl overflow-hidden" className='rounded-xl overflow-hidden'
size="middle" size='middle'
pagination={{ pagination={{
formatPageText: (page) => formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', { t('第 {{start}} - {{end}} 条,共 {{total}} 条', {

View File

@@ -41,12 +41,13 @@ export const getModelCategories = (() => {
all: { all: {
label: t('全部模型'), label: t('全部模型'),
icon: null, icon: null,
filter: () => true filter: () => true,
}, },
openai: { openai: {
label: 'OpenAI', label: 'OpenAI',
icon: <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('dall-e') ||
model.model_name.toLowerCase().includes('whisper') || model.model_name.toLowerCase().includes('whisper') ||
model.model_name.toLowerCase().includes('tts') || model.model_name.toLowerCase().includes('tts') ||
@@ -54,109 +55,110 @@ export const getModelCategories = (() => {
model.model_name.toLowerCase().includes('babbage') || model.model_name.toLowerCase().includes('babbage') ||
model.model_name.toLowerCase().includes('davinci') || model.model_name.toLowerCase().includes('davinci') ||
model.model_name.toLowerCase().includes('curie') || model.model_name.toLowerCase().includes('curie') ||
model.model_name.toLowerCase().includes('ada') model.model_name.toLowerCase().includes('ada'),
}, },
anthropic: { anthropic: {
label: 'Anthropic', label: 'Anthropic',
icon: <Claude.Color />, icon: <Claude.Color />,
filter: (model) => model.model_name.toLowerCase().includes('claude') filter: (model) => model.model_name.toLowerCase().includes('claude'),
}, },
gemini: { gemini: {
label: 'Gemini', label: 'Gemini',
icon: <Gemini.Color />, icon: <Gemini.Color />,
filter: (model) => model.model_name.toLowerCase().includes('gemini') filter: (model) => model.model_name.toLowerCase().includes('gemini'),
}, },
moonshot: { moonshot: {
label: 'Moonshot', label: 'Moonshot',
icon: <Moonshot />, icon: <Moonshot />,
filter: (model) => model.model_name.toLowerCase().includes('moonshot') filter: (model) => model.model_name.toLowerCase().includes('moonshot'),
}, },
zhipu: { zhipu: {
label: t('智谱'), label: t('智谱'),
icon: <Zhipu.Color />, icon: <Zhipu.Color />,
filter: (model) => model.model_name.toLowerCase().includes('chatglm') || filter: (model) =>
model.model_name.toLowerCase().includes('glm-') model.model_name.toLowerCase().includes('chatglm') ||
model.model_name.toLowerCase().includes('glm-'),
}, },
qwen: { qwen: {
label: t('通义千问'), label: t('通义千问'),
icon: <Qwen.Color />, icon: <Qwen.Color />,
filter: (model) => model.model_name.toLowerCase().includes('qwen') filter: (model) => model.model_name.toLowerCase().includes('qwen'),
}, },
deepseek: { deepseek: {
label: 'DeepSeek', label: 'DeepSeek',
icon: <DeepSeek.Color />, icon: <DeepSeek.Color />,
filter: (model) => model.model_name.toLowerCase().includes('deepseek') filter: (model) => model.model_name.toLowerCase().includes('deepseek'),
}, },
minimax: { minimax: {
label: 'MiniMax', label: 'MiniMax',
icon: <Minimax.Color />, icon: <Minimax.Color />,
filter: (model) => model.model_name.toLowerCase().includes('abab') filter: (model) => model.model_name.toLowerCase().includes('abab'),
}, },
baidu: { baidu: {
label: t('文心一言'), label: t('文心一言'),
icon: <Wenxin.Color />, icon: <Wenxin.Color />,
filter: (model) => model.model_name.toLowerCase().includes('ernie') filter: (model) => model.model_name.toLowerCase().includes('ernie'),
}, },
xunfei: { xunfei: {
label: t('讯飞星火'), label: t('讯飞星火'),
icon: <Spark.Color />, icon: <Spark.Color />,
filter: (model) => model.model_name.toLowerCase().includes('spark') filter: (model) => model.model_name.toLowerCase().includes('spark'),
}, },
midjourney: { midjourney: {
label: 'Midjourney', label: 'Midjourney',
icon: <Midjourney />, icon: <Midjourney />,
filter: (model) => model.model_name.toLowerCase().includes('mj_') filter: (model) => model.model_name.toLowerCase().includes('mj_'),
}, },
tencent: { tencent: {
label: t('腾讯混元'), label: t('腾讯混元'),
icon: <Hunyuan.Color />, icon: <Hunyuan.Color />,
filter: (model) => model.model_name.toLowerCase().includes('hunyuan') filter: (model) => model.model_name.toLowerCase().includes('hunyuan'),
}, },
cohere: { cohere: {
label: 'Cohere', label: 'Cohere',
icon: <Cohere.Color />, icon: <Cohere.Color />,
filter: (model) => model.model_name.toLowerCase().includes('command') filter: (model) => model.model_name.toLowerCase().includes('command'),
}, },
cloudflare: { cloudflare: {
label: 'Cloudflare', label: 'Cloudflare',
icon: <Cloudflare.Color />, icon: <Cloudflare.Color />,
filter: (model) => model.model_name.toLowerCase().includes('@cf/') filter: (model) => model.model_name.toLowerCase().includes('@cf/'),
}, },
ai360: { ai360: {
label: t('360智脑'), label: t('360智脑'),
icon: <Ai360.Color />, icon: <Ai360.Color />,
filter: (model) => model.model_name.toLowerCase().includes('360') filter: (model) => model.model_name.toLowerCase().includes('360'),
}, },
yi: { yi: {
label: t('零一万物'), label: t('零一万物'),
icon: <Yi.Color />, icon: <Yi.Color />,
filter: (model) => model.model_name.toLowerCase().includes('yi') filter: (model) => model.model_name.toLowerCase().includes('yi'),
}, },
jina: { jina: {
label: 'Jina', label: 'Jina',
icon: <Jina />, icon: <Jina />,
filter: (model) => model.model_name.toLowerCase().includes('jina') filter: (model) => model.model_name.toLowerCase().includes('jina'),
}, },
mistral: { mistral: {
label: 'Mistral AI', label: 'Mistral AI',
icon: <Mistral.Color />, icon: <Mistral.Color />,
filter: (model) => model.model_name.toLowerCase().includes('mistral') filter: (model) => model.model_name.toLowerCase().includes('mistral'),
}, },
xai: { xai: {
label: 'xAI', label: 'xAI',
icon: <XAI />, icon: <XAI />,
filter: (model) => model.model_name.toLowerCase().includes('grok') filter: (model) => model.model_name.toLowerCase().includes('grok'),
}, },
llama: { llama: {
label: 'Llama', label: 'Llama',
icon: <Ollama />, icon: <Ollama />,
filter: (model) => model.model_name.toLowerCase().includes('llama') filter: (model) => model.model_name.toLowerCase().includes('llama'),
}, },
doubao: { doubao: {
label: t('豆包'), label: t('豆包'),
icon: <Doubao.Color />, icon: <Doubao.Color />,
filter: (model) => model.model_name.toLowerCase().includes('doubao') filter: (model) => model.model_name.toLowerCase().includes('doubao'),
} },
}; };
lastLocale = currentLocale; lastLocale = currentLocale;
@@ -299,7 +301,13 @@ export function stringToColor(str) {
// 渲染带有模型图标的标签 // 渲染带有模型图标的标签
export function renderModelTag(modelName, options = {}) { 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); const categories = getModelCategories(i18next.t);
let icon = null; let icon = null;
@@ -647,6 +655,9 @@ export function renderModelPrice(
fileSearch = false, fileSearch = false,
fileSearchCallCount = 0, fileSearchCallCount = 0,
fileSearchPrice = 0, fileSearchPrice = 0,
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
) { ) {
if (modelPrice !== -1) { if (modelPrice !== -1) {
return i18next.t( return i18next.t(
@@ -674,9 +685,12 @@ export function renderModelPrice(
effectiveInputTokens = effectiveInputTokens =
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio; inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
} }
if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens;
}
let price = let price =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio + (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio + (completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio; (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
@@ -685,8 +699,11 @@ export function renderModelPrice(
<> <>
<article> <article>
<p> <p>
{i18next.t('输入价格:${{price}} / 1M tokens', { {i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
price: inputRatioPrice, price: inputRatioPrice,
audioPrice: audioInputSeperatePrice
? `,音频 $${audioInputPrice} / 1M tokens`
: '',
})} })}
</p> </p>
<p> <p>
@@ -740,96 +757,93 @@ export function renderModelPrice(
)} )}
<p></p> <p></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}}', let inputDesc = '';
{ if (image && imageOutputTokens > 0) {
nonCacheInput: inputTokens - cacheTokens, inputDesc = i18next.t(
cacheInput: cacheTokens, '(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
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}}',
{ {
nonImageInput: inputTokens - imageOutputTokens, nonImageInput: inputTokens - imageOutputTokens,
imageInput: imageOutputTokens, imageInput: imageOutputTokens,
imageRatio: imageRatio, imageRatio: imageRatio,
price: inputRatioPrice, price: inputRatioPrice,
},
);
} 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, completion: completionTokens,
compPrice: completionRatioPrice, compPrice: completionRatioPrice,
ratio: groupRatio, ratio: groupRatio,
total: price.toFixed(6),
}, },
) );
: webSearch && webSearchCallCount > 0 && !image && !fileSearch
// 构建额外服务描述
const extraServices = [
webSearch && webSearchCallCount > 0
? i18next.t( ? i18next.t(
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}} = ${{total}}', ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{ {
input: inputTokens, count: webSearchCallCount,
price: inputRatioPrice, price: webSearchPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio, ratio: groupRatio,
webSearchCallCount,
webSearchPrice,
total: price.toFixed(6),
}, },
) )
: fileSearch && : '',
fileSearchCallCount > 0 && fileSearch && fileSearchCallCount > 0
!image &&
!webSearch
? i18next.t( ? i18next.t(
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}', ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{ {
input: inputTokens, count: fileSearchCallCount,
price: inputRatioPrice, price: fileSearchPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio, ratio: groupRatio,
fileSearchCallCount,
fileSearchPrice,
total: price.toFixed(6),
}, },
) )
: webSearch && : '',
webSearchCallCount > 0 && ].join('');
fileSearch &&
fileSearchCallCount > 0 && return i18next.t(
!image '{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
? 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, inputDesc,
price: inputRatioPrice, outputDesc,
completion: completionTokens, extraServices,
compPrice: completionRatioPrice,
ratio: groupRatio,
webSearchCallCount,
webSearchPrice,
fileSearchCallCount,
fileSearchPrice,
total: price.toFixed(6), 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),
},
)}
</p> </p>
<p>{i18next.t('仅供参考,以实际扣费为准')}</p> <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
</article> </article>
@@ -1333,7 +1347,9 @@ export function rehypeSplitWordsIntoSpans(options = {}) {
visit(tree, 'element', (node) => { visit(tree, 'element', (node) => {
if ( 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 node.children
) { ) {
const newChildren = []; const newChildren = [];
@@ -1341,7 +1357,9 @@ export function rehypeSplitWordsIntoSpans(options = {}) {
if (child.type === 'text') { if (child.type === 'text') {
try { try {
// 使用 Intl.Segmenter 精准拆分中英文及标点 // 使用 Intl.Segmenter 精准拆分中英文及标点
const segmenter = new Intl.Segmenter('zh', { granularity: 'word' }); const segmenter = new Intl.Segmenter('zh', {
granularity: 'word',
});
const segments = segmenter.segment(child.value); const segments = segmenter.segment(child.value);
Array.from(segments) Array.from(segments)