Merge branch 'gemini-audio-billing' into alpha

This commit is contained in:
creamlike1024
2025-06-07 12:32:20 +08:00
8 changed files with 480 additions and 287 deletions

View File

@@ -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

View File

@@ -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的行为

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

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