feat: gemini audio input billing
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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的行为
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}} 条', {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user