Compare commits

...

3 Commits

Author SHA1 Message Date
IcedTangerine
572ee2b919 Merge branch 'main' into gemini-3-pro-image-preview-billing 2025-12-30 18:24:27 +08:00
creamlike1024
2ae56ad842 feat: add openai /v1/images image output token billing 2025-12-29 20:24:52 +08:00
creamlike1024
6a3f8b1005 feat: add image completion ratio 2025-12-25 13:34:26 +08:00
15 changed files with 285 additions and 76 deletions

View File

@@ -146,6 +146,15 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "ImageOutputRatio":
err = ratio_setting.UpdateImageOutputRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "图片输出倍率设置失败: " + err.Error(),
})
return
}
case "AudioRatio":
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
if err != nil {

View File

@@ -367,14 +367,15 @@ type GeminiChatResponse struct {
}
type GeminiUsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokensDetails []GeminiTokensDetails `json:"promptTokensDetails"`
CandidatesTokensDetails []GeminiTokensDetails `json:"candidatesTokensDetails"`
}
type GeminiPromptTokensDetails struct {
type GeminiTokensDetails struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
}

View File

@@ -259,6 +259,7 @@ type InputTokenDetails struct {
type OutputTokenDetails struct {
TextTokens int `json:"text_tokens"`
ImageTokens int `json:"image_tokens"`
AudioTokens int `json:"audio_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
}

View File

@@ -119,6 +119,7 @@ func InitOptionMap() {
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
common.OptionMap["ImageOutputRatio"] = ratio_setting.ImageOutputRatio2JSONString()
common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
@@ -426,6 +427,8 @@ func updateOptionMap(key string, value string) (err error) {
err = ratio_setting.UpdateCacheRatioByJSONString(value)
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(value)
case "ImageOutputRatio":
err = ratio_setting.UpdateImageOutputRatioByJSONString(value)
case "AudioRatio":
err = ratio_setting.UpdateAudioRatioByJSONString(value)
case "AudioCompletionRatio":

View File

@@ -1067,9 +1067,34 @@ func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.Ch
return nil
}
func applyGeminiTokensDetailsToPromptUsage(usage *dto.Usage, details []dto.GeminiTokensDetails) {
for _, detail := range details {
switch detail.Modality {
case "AUDIO":
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
case "TEXT":
usage.PromptTokensDetails.TextTokens = detail.TokenCount
case "IMAGE":
usage.PromptTokensDetails.ImageTokens = detail.TokenCount
}
}
}
func applyGeminiTokensDetailsToCompletionUsage(usage *dto.Usage, details []dto.GeminiTokensDetails) {
for _, detail := range details {
switch detail.Modality {
case "AUDIO":
usage.CompletionTokenDetails.AudioTokens = detail.TokenCount
case "TEXT":
usage.CompletionTokenDetails.TextTokens = detail.TokenCount
case "IMAGE":
usage.CompletionTokenDetails.ImageTokens = detail.TokenCount
}
}
}
func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, callback func(data string, geminiResponse *dto.GeminiChatResponse) bool) (*dto.Usage, *types.NewAPIError) {
var usage = &dto.Usage{}
var imageCount int
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
@@ -1080,12 +1105,8 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return false
}
// 统计图片数量
for _, candidate := range geminiResponse.Candidates {
for _, part := range candidate.Content.Parts {
if part.InlineData != nil && part.InlineData.MimeType != "" {
imageCount++
}
if part.Text != "" {
responseText.WriteString(part.Text)
}
@@ -1093,31 +1114,30 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
// 更新使用量统计
if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
if geminiResponse.UsageMetadata.TotalTokenCount != 0 ||
geminiResponse.UsageMetadata.PromptTokenCount != 0 ||
geminiResponse.UsageMetadata.CandidatesTokenCount != 0 ||
geminiResponse.UsageMetadata.ThoughtsTokenCount != 0 ||
len(geminiResponse.UsageMetadata.PromptTokensDetails) > 0 ||
len(geminiResponse.UsageMetadata.CandidatesTokensDetails) > 0 {
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
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
}
if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
} else {
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
applyGeminiTokensDetailsToPromptUsage(usage, geminiResponse.UsageMetadata.PromptTokensDetails)
applyGeminiTokensDetailsToCompletionUsage(usage, geminiResponse.UsageMetadata.CandidatesTokensDetails)
}
return callback(data, &geminiResponse)
})
if imageCount != 0 {
if usage.CompletionTokens == 0 {
usage.CompletionTokens = imageCount * 1400
}
}
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
if usage.TotalTokens > 0 {
if usage.TotalTokens > 0 && usage.PromptTokens > 0 {
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
}
@@ -1223,23 +1243,23 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
}
fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
fullTextResponse.Model = info.UpstreamModelName
usage := dto.Usage{
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount,
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
}
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
}
if usage.TotalTokens > 0 {
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
} else {
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
applyGeminiTokensDetailsToPromptUsage(&usage, geminiResponse.UsageMetadata.PromptTokensDetails)
applyGeminiTokensDetailsToCompletionUsage(&usage, geminiResponse.UsageMetadata.CandidatesTokensDetails)
fullTextResponse.Usage = usage
switch info.RelayFormat {

View File

@@ -12,8 +12,10 @@ import (
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relay/channel/openrouter"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
@@ -582,6 +584,11 @@ func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *h
usageResp.PromptTokensDetails.ImageTokens += usageResp.InputTokensDetails.ImageTokens
usageResp.PromptTokensDetails.TextTokens += usageResp.InputTokensDetails.TextTokens
}
if (info.RelayMode == relayconstant.RelayModeImagesGenerations || info.RelayMode == relayconstant.RelayModeImagesEdits) && usageResp.OutputTokens > 0 {
if _, ok := ratio_setting.GetImageOutputRatio(info.OriginModelName); ok {
usageResp.CompletionTokenDetails.ImageTokens += usageResp.OutputTokens
}
}
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
return &usageResp.Usage, nil
}

View File

@@ -203,6 +203,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
cacheTokens := usage.PromptTokensDetails.CachedTokens
imageTokens := usage.PromptTokensDetails.ImageTokens
audioTokens := usage.PromptTokensDetails.AudioTokens
completionImageTokens := usage.CompletionTokenDetails.ImageTokens
completionTokens := usage.CompletionTokens
cachedCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
@@ -212,6 +213,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
completionRatio := relayInfo.PriceData.CompletionRatio
cacheRatio := relayInfo.PriceData.CacheRatio
imageRatio := relayInfo.PriceData.ImageRatio
imageOutputRatio := relayInfo.PriceData.ImageOutputRatio
modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
modelPrice := relayInfo.PriceData.ModelPrice
@@ -223,10 +225,12 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
dImageTokens := decimal.NewFromInt(int64(imageTokens))
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
dCompletionImageTokens := decimal.NewFromInt(int64(completionImageTokens))
dCachedCreationTokens := decimal.NewFromInt(int64(cachedCreationTokens))
dCompletionRatio := decimal.NewFromFloat(completionRatio)
dCacheRatio := decimal.NewFromFloat(cacheRatio)
dImageRatio := decimal.NewFromFloat(imageRatio)
dImageOutputRatio := decimal.NewFromFloat(imageOutputRatio)
dModelRatio := decimal.NewFromFloat(modelRatio)
dGroupRatio := decimal.NewFromFloat(groupRatio)
dModelPrice := decimal.NewFromFloat(modelPrice)
@@ -338,7 +342,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
Add(imageTokensWithRatio).
Add(dCachedCreationTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
baseCompletionTokens := dCompletionTokens
var completionImageTokensWithRatio decimal.Decimal
if !dCompletionImageTokens.IsZero() {
baseCompletionTokens = baseCompletionTokens.Sub(dCompletionImageTokens)
completionImageTokensWithRatio = dCompletionImageTokens.Mul(dImageOutputRatio)
}
completionQuota := baseCompletionTokens.Mul(dCompletionRatio).Add(completionImageTokensWithRatio)
quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
@@ -424,7 +435,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
if imageTokens != 0 {
other["image"] = true
other["image_ratio"] = imageRatio
other["image_output"] = imageTokens
other["image_input_tokens"] = imageTokens
}
if completionImageTokens != 0 {
other["completion_image_tokens"] = completionImageTokens
other["image_output_ratio"] = imageOutputRatio
}
if cachedCreationTokens != 0 {
other["cache_creation_tokens"] = cachedCreationTokens

View File

@@ -60,6 +60,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
var cacheCreationRatio1h float64
var audioRatio float64
var audioCompletionRatio float64
var imageOutputRatio float64
var freeModel bool
if !usePrice {
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
@@ -85,6 +86,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
// 固定1h和5min缓存写入价格的比例
cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
imageOutputRatio, _ = ratio_setting.GetImageOutputRatio(info.OriginModelName)
audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
@@ -124,6 +126,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
UsePrice: usePrice,
CacheRatio: cacheRatio,
ImageRatio: imageRatio,
ImageOutputRatio: imageOutputRatio,
AudioRatio: audioRatio,
AudioCompletionRatio: audioCompletionRatio,
CacheCreationRatio: cacheCreationRatio,

View File

@@ -42,10 +42,14 @@ func GetExposedData() gin.H {
return cloneGinH(c.data)
}
newData := gin.H{
"model_ratio": GetModelRatioCopy(),
"completion_ratio": GetCompletionRatioCopy(),
"cache_ratio": GetCacheRatioCopy(),
"model_price": GetModelPriceCopy(),
"model_ratio": GetModelRatioCopy(),
"completion_ratio": GetCompletionRatioCopy(),
"cache_ratio": GetCacheRatioCopy(),
"model_price": GetModelPriceCopy(),
"image_ratio": GetImageRatioCopy(),
"image_output_ratio": GetImageOutputRatioCopy(),
"audio_ratio": GetAudioRatioCopy(),
"audio_completion_ratio": GetAudioCompletionRatioCopy(),
}
exposedData.Store(&exposedCache{
data: newData,

View File

@@ -361,6 +361,11 @@ func InitRatioSettings() {
imageRatioMap = defaultImageRatio
imageRatioMapMutex.Unlock()
// initialize imageOutputRatioMap
imageOutputRatioMapMutex.Lock()
imageOutputRatioMap = defaultImageOutputRatio
imageOutputRatioMapMutex.Unlock()
// initialize audioRatioMap
audioRatioMapMutex.Lock()
audioRatioMap = defaultAudioRatio
@@ -686,6 +691,10 @@ var defaultImageRatio = map[string]float64{
}
var imageRatioMap map[string]float64
var imageRatioMapMutex sync.RWMutex
var defaultImageOutputRatio = map[string]float64{}
var imageOutputRatioMap map[string]float64
var imageOutputRatioMapMutex sync.RWMutex
var (
audioRatioMap map[string]float64 = nil
audioRatioMapMutex = sync.RWMutex{}
@@ -709,7 +718,11 @@ func UpdateImageRatioByJSONString(jsonStr string) error {
imageRatioMapMutex.Lock()
defer imageRatioMapMutex.Unlock()
imageRatioMap = make(map[string]float64)
return common.Unmarshal([]byte(jsonStr), &imageRatioMap)
err := common.Unmarshal([]byte(jsonStr), &imageRatioMap)
if err == nil {
InvalidateExposedDataCache()
}
return err
}
func GetImageRatio(name string) (float64, bool) {
@@ -722,6 +735,37 @@ func GetImageRatio(name string) (float64, bool) {
return ratio, true
}
func ImageOutputRatio2JSONString() string {
imageOutputRatioMapMutex.RLock()
defer imageOutputRatioMapMutex.RUnlock()
jsonBytes, err := common.Marshal(imageOutputRatioMap)
if err != nil {
common.SysError("error marshalling image output ratio: " + err.Error())
}
return string(jsonBytes)
}
func UpdateImageOutputRatioByJSONString(jsonStr string) error {
imageOutputRatioMapMutex.Lock()
defer imageOutputRatioMapMutex.Unlock()
imageOutputRatioMap = make(map[string]float64)
err := common.Unmarshal([]byte(jsonStr), &imageOutputRatioMap)
if err == nil {
InvalidateExposedDataCache()
}
return err
}
func GetImageOutputRatio(name string) (float64, bool) {
imageOutputRatioMapMutex.RLock()
defer imageOutputRatioMapMutex.RUnlock()
ratio, ok := imageOutputRatioMap[name]
if !ok {
return 1, false
}
return ratio, true
}
func AudioRatio2JSONString() string {
audioRatioMapMutex.RLock()
defer audioRatioMapMutex.RUnlock()
@@ -787,6 +831,26 @@ func GetAudioCompletionRatioCopy() map[string]float64 {
return copyMap
}
func GetImageRatioCopy() map[string]float64 {
imageRatioMapMutex.RLock()
defer imageRatioMapMutex.RUnlock()
copyMap := make(map[string]float64, len(imageRatioMap))
for k, v := range imageRatioMap {
copyMap[k] = v
}
return copyMap
}
func GetImageOutputRatioCopy() map[string]float64 {
imageOutputRatioMapMutex.RLock()
defer imageOutputRatioMapMutex.RUnlock()
copyMap := make(map[string]float64, len(imageOutputRatioMap))
for k, v := range imageOutputRatioMap {
copyMap[k] = v
}
return copyMap
}
func GetModelRatioCopy() map[string]float64 {
modelRatioMapMutex.RLock()
defer modelRatioMapMutex.RUnlock()

View File

@@ -18,6 +18,7 @@ type PriceData struct {
CacheCreation5mRatio float64
CacheCreation1hRatio float64
ImageRatio float64
ImageOutputRatio float64
AudioRatio float64
AudioCompletionRatio float64
OtherRatios map[string]float64

View File

@@ -40,6 +40,7 @@ const RatioSetting = () => {
GroupRatio: '',
GroupGroupRatio: '',
ImageRatio: '',
ImageOutputRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
AutoGroups: '',

View File

@@ -1174,7 +1174,9 @@ export function renderModelPrice(
cacheRatio = 1.0,
image = false,
imageRatio = 1.0,
imageOutputTokens = 0,
imageInputTokens = 0,
completionImageTokens = 0,
imageOutputRatio = 1.0,
webSearch = false,
webSearchCallCount = 0,
webSearchPrice = 0,
@@ -1217,22 +1219,31 @@ export function renderModelPrice(
let completionRatioPrice = modelRatio * 2.0 * completionRatio;
let cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
let imageRatioPrice = modelRatio * 2.0 * imageRatio;
let imageOutputRatioPrice = modelRatio * 2.0 * imageOutputRatio;
// Calculate effective input tokens (non-cached + cached with ratio applied)
let effectiveInputTokens =
inputTokens - cacheTokens + cacheTokens * cacheRatio;
// Handle image tokens if present
if (image && imageOutputTokens > 0) {
if (image && imageInputTokens > 0) {
effectiveInputTokens =
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
inputTokens - imageInputTokens + imageInputTokens * imageRatio;
}
if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens;
}
const baseCompletionTokens = Math.max(
completionTokens - completionImageTokens,
0,
);
const shouldSplitOutput =
completionImageTokens > 0 && imageOutputRatio !== completionRatio;
let price =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(baseCompletionTokens / 1000000) * completionRatioPrice * groupRatio +
(completionImageTokens / 1000000) * imageOutputRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
imageGenerationCallPrice * groupRatio;
@@ -1252,17 +1263,44 @@ export function renderModelPrice(
},
)}
</p>
<p>
{i18next.t(
'输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
{
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (completionRatioPrice * rate).toFixed(6),
completionRatio: completionRatio,
},
)}
</p>
{shouldSplitOutput ? (
<>
<p>
{i18next.t(
'文字输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
{
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (completionRatioPrice * rate).toFixed(6),
completionRatio: completionRatio,
},
)}
</p>
<p>
{i18next.t(
'图片输出价格:{{symbol}}{{price}} * {{imageOutputRatio}} = {{symbol}}{{total}} / 1M tokens (图片输出倍率: {{imageOutputRatio}})',
{
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (imageOutputRatioPrice * rate).toFixed(6),
imageOutputRatio: imageOutputRatio,
},
)}
</p>
</>
) : (
<p>
{i18next.t(
'输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
{
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
total: (completionRatioPrice * rate).toFixed(6),
completionRatio: completionRatio,
},
)}
</p>
)}
{cacheTokens > 0 && (
<p>
{i18next.t(
@@ -1276,7 +1314,7 @@ export function renderModelPrice(
)}
</p>
)}
{image && imageOutputTokens > 0 && (
{image && imageInputTokens > 0 && (
<p>
{i18next.t(
'图片输入价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (图片倍率: {{imageRatio}})',
@@ -1318,12 +1356,12 @@ export function renderModelPrice(
{(() => {
// 构建输入部分描述
let inputDesc = '';
if (image && imageOutputTokens > 0) {
if (image && imageInputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}',
{
nonImageInput: inputTokens - imageOutputTokens,
imageInput: imageOutputTokens,
nonImageInput: inputTokens - imageInputTokens,
imageInput: imageInputTokens,
imageRatio: imageRatio,
symbol: symbol,
price: (inputRatioPrice * rate).toFixed(6),
@@ -1363,16 +1401,29 @@ export function renderModelPrice(
}
// 构建输出部分描述
const outputDesc = i18next.t(
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
{
completion: completionTokens,
symbol: symbol,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
);
const outputDesc = shouldSplitOutput
? i18next.t(
'输出 文字输出 {{textCompletion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 图片输出 {{imageCompletion}} tokens / 1M tokens * {{symbol}}{{imageCompPrice}}) * {{ratioType}} {{ratio}}',
{
textCompletion: baseCompletionTokens,
imageCompletion: completionImageTokens,
symbol: symbol,
textCompPrice: (completionRatioPrice * rate).toFixed(6),
imageCompPrice: (imageOutputRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: i18next.t(
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
{
completion: completionTokens,
symbol: symbol,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
},
);
// 构建额外服务描述
const extraServices = [

View File

@@ -451,7 +451,9 @@ export const useLogsData = () => {
other?.cache_ratio || 1.0,
other?.image || false,
other?.image_ratio || 0,
other?.image_output || 0,
other?.image_input_tokens || 0,
other?.completion_image_tokens || 0,
other?.image_output_ratio || 1.0,
other?.web_search || false,
other?.web_search_call_count || 0,
other?.web_search_price || 0,

View File

@@ -45,6 +45,7 @@ export default function ModelRatioSettings(props) {
CacheRatio: '',
CompletionRatio: '',
ImageRatio: '',
ImageOutputRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
ExposeRatioEnabled: false,
@@ -246,6 +247,32 @@ export default function ModelRatioSettings(props) {
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('图片输出倍率(仅部分模型支持该计费)')}
extraText={t(
'图片输出相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费',
)}
placeholder={t(
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gemini-3-pro-image-preview": 60}',
)}
field={'ImageOutputRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, ImageOutputRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea