mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
Token estimates badly undercounted agent requests. Claude tool definitions, Responses function calls and their outputs, Chat assistant tool calls, and Gemini system instructions, function calls, function responses, and tools never reached the counter; Chat tool schemas were counted as Go map syntax and Claude tool results as escaped JSON. Measured against upstream usage on 54 real Claude Code and Codex conversation windows, native prompt estimates were 31-93% low, and the Claude and Gemini completion fallbacks about 90% low because streamed tool calls were not counted. All of these now reach the counter. The completion fallbacks count tool names and arguments, plus 40 tokens per Claude tool_use block. The estimator weights are refitted per tokenizer: Claude before Opus 4.7, Claude 4.7 and later (about 30% more tokens for the same text), GPT, and Gemini. Prompt estimates now land within 1% bias and 3-4% mean error for each family. Token counting moves into tokenkit, an independent module like relaykit, so other gateways can use it. It also replaces the flat 520-token image estimate with each provider's sizing rules, checked against upstream usage on sizes outside the test table: OpenAI patch and tile rules per model and detail level (gpt-5.5 and later default to original detail, and their high detail is billed as measured, not as documented), Claude's two resolution tiers, and Gemini 3 media resolution and Gemini 2.x tiles. Chat image_url parts without detail no longer default to high in ParseContent, so they estimate as the upstream default auto; no converter sent that value upstream. tiktoken-go moves to v0.8.1, about twice as fast on ordinary text. It requires Go 1.26, so the root module and tokenkit move to Go 1.26; relaykit stays on 1.25.1. In every version BPE cost grows with the square of a pre-tokenized piece, so 100,000 Chinese characters without punctuation took 21.6 s to count during pre-consume. Count now cuts single-class runs longer than 1024 bytes, which leaves ordinary text unchanged and brings that input down to 106 ms. The encoder cache no longer grows with client-supplied model names.
271 lines
8.3 KiB
Go
271 lines
8.3 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
constant2 "github.com/QuantumNous/new-api/relay/constant"
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/QuantumNous/new-api/relaykit/types"
|
|
"github.com/QuantumNous/new-api/tokenkit"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, stream bool) (int, error) {
|
|
if fileMeta == nil || fileMeta.Source == nil {
|
|
return 0, fmt.Errorf("image_url_is_nil")
|
|
}
|
|
|
|
img := tokenkit.Image{Detail: fileMeta.Detail}
|
|
// Reading the size may download a URL image; operators can turn that off,
|
|
// and the estimate then assumes a typical image size.
|
|
measure := constant.GetMediaToken && (stream || constant.GetMediaTokenNotStream)
|
|
if !measure || !tokenkit.ImageNeedsSize(model, img.Detail) {
|
|
return tokenkit.ImageTokens(model, img), nil
|
|
}
|
|
|
|
config, format, err := GetImageConfig(c, fileMeta.Source)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if (config.Width == 0 || config.Height == 0) && format == "" {
|
|
return 0, fmt.Errorf("fail to decode image config: %s", fileMeta.GetIdentifier())
|
|
}
|
|
img.Width, img.Height = config.Width, config.Height
|
|
logger.LogDebug(c, "image token input: format=%s, width=%d, height=%d", format, img.Width, img.Height)
|
|
return tokenkit.ImageTokens(model, img), nil
|
|
}
|
|
|
|
func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) {
|
|
// 是否统计token
|
|
if !constant.CountToken {
|
|
return 0, nil
|
|
}
|
|
return CountRequestToken(c, meta, info)
|
|
}
|
|
|
|
// CountRequestToken counts request tokens regardless of the billing estimation
|
|
// switch. Utility endpoints such as Claude's messages/count_tokens must remain
|
|
// available even when operators disable request-token estimation for relays.
|
|
func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) {
|
|
if meta == nil {
|
|
return 0, errors.New("token count meta is nil")
|
|
}
|
|
|
|
if info.RelayFormat == types.RelayFormatOpenAIRealtime {
|
|
return 0, nil
|
|
}
|
|
if info.RelayMode == constant2.RelayModeAudioTranscription || info.RelayMode == constant2.RelayModeAudioTranslation {
|
|
multiForm, err := common.ParseMultipartFormReusable(c)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("error parsing multipart form: %v", err)
|
|
}
|
|
fileHeaders := multiForm.File["file"]
|
|
totalAudioToken := 0
|
|
for _, fileHeader := range fileHeaders {
|
|
file, err := fileHeader.Open()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("error opening audio file: %v", err)
|
|
}
|
|
defer file.Close()
|
|
// get ext and io.seeker
|
|
ext := filepath.Ext(fileHeader.Filename)
|
|
duration, err := common.GetAudioDuration(c.Request.Context(), file, ext)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("error getting audio duration: %v", err)
|
|
}
|
|
// duration 来自用户上传文件的元数据,可被伪造成天文数字或负数。
|
|
// 负值会让 token 估算变成负数(低估预扣费),先钳到 0 再转换。
|
|
if duration < 0 {
|
|
duration = 0
|
|
}
|
|
// 一分钟 1000 token,与 $price / minute 对齐。
|
|
totalAudioToken += common.QuotaRound(math.Ceil(duration) / 60.0 * 1000)
|
|
}
|
|
return totalAudioToken, nil
|
|
}
|
|
|
|
model := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
|
|
tkm := 0
|
|
|
|
if meta.TokenType == types.TokenTypeTextNumber {
|
|
tkm += utf8.RuneCountInString(meta.CombineText)
|
|
} else {
|
|
tkm += tokenkit.Count(model, meta.CombineText)
|
|
}
|
|
|
|
if info.RelayFormat == types.RelayFormatOpenAI {
|
|
tkm += meta.ToolsCount * 8
|
|
tkm += meta.MessagesCount * 3 // 每条消息的格式化token数量
|
|
tkm += meta.NameCount * 3
|
|
tkm += 3
|
|
}
|
|
|
|
shouldFetchFiles := true
|
|
|
|
if info.RelayFormat == types.RelayFormatGemini {
|
|
shouldFetchFiles = false
|
|
}
|
|
|
|
// 是否本地计算媒体token数量
|
|
if !constant.GetMediaToken {
|
|
shouldFetchFiles = false
|
|
}
|
|
|
|
// 是否在非流模式下本地计算媒体token数量
|
|
if !constant.GetMediaTokenNotStream && !info.IsStream {
|
|
shouldFetchFiles = false
|
|
}
|
|
|
|
// 使用统一的文件服务获取文件类型
|
|
for _, file := range meta.Files {
|
|
if file.Source == nil {
|
|
continue
|
|
}
|
|
|
|
// 如果文件类型未知且需要获取,通过 MIME 类型检测
|
|
if file.FileType == "" || (file.Source.IsURL() && shouldFetchFiles) {
|
|
// 注意:这里我们直接调用 LoadFileSource 而不是 GetMimeType
|
|
// 因为 GetMimeType 内部可能会调用 GetFileTypeFromUrl (HEAD 请求)
|
|
// 而我们这里既然要计算 token,通常需要完整数据
|
|
cachedData, err := LoadFileSource(c, file.Source, "token_counter")
|
|
if err != nil {
|
|
if shouldFetchFiles {
|
|
return 0, fmt.Errorf("error getting file type: %v", err)
|
|
}
|
|
continue
|
|
}
|
|
file.FileType = DetectFileType(cachedData.MimeType)
|
|
}
|
|
}
|
|
|
|
for i, file := range meta.Files {
|
|
switch file.FileType {
|
|
case types.FileTypeImage:
|
|
token, err := getImageToken(c, file, model, info.IsStream)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("error counting image token, media index[%d], identifier[%s], err: %v", i, file.GetIdentifier(), err)
|
|
}
|
|
tkm += token
|
|
case types.FileTypeAudio:
|
|
tkm += 256
|
|
case types.FileTypeVideo:
|
|
tkm += 4096 * 2
|
|
case types.FileTypeFile:
|
|
tkm += 4096
|
|
default:
|
|
tkm += 4096 // Default case for unknown file types
|
|
}
|
|
}
|
|
|
|
common.SetContextKey(c, constant.ContextKeyPromptTokens, tkm)
|
|
return tkm, nil
|
|
}
|
|
|
|
func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent, model string) (int, int, error) {
|
|
audioToken := 0
|
|
textToken := 0
|
|
switch request.Type {
|
|
case dto.RealtimeEventTypeSessionUpdate:
|
|
if request.Session != nil {
|
|
msgTokens := tokenkit.Count(model, request.Session.Instructions)
|
|
textToken += msgTokens
|
|
}
|
|
case dto.RealtimeEventResponseAudioDelta:
|
|
// count audio token
|
|
atk, err := CountAudioTokenOutput(request.Delta, info.OutputAudioFormat)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("error counting audio token: %v", err)
|
|
}
|
|
audioToken += atk
|
|
case dto.RealtimeEventResponseAudioTranscriptionDelta, dto.RealtimeEventResponseFunctionCallArgumentsDelta:
|
|
// count text token
|
|
tkm := tokenkit.Count(model, request.Delta)
|
|
textToken += tkm
|
|
case dto.RealtimeEventInputAudioBufferAppend:
|
|
// count audio token
|
|
atk, err := CountAudioTokenInput(request.Audio, info.InputAudioFormat)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("error counting audio token: %v", err)
|
|
}
|
|
audioToken += atk
|
|
case dto.RealtimeEventConversationItemCreated:
|
|
if request.Item != nil {
|
|
switch request.Item.Type {
|
|
case "message":
|
|
for _, content := range request.Item.Content {
|
|
if content.Type == "input_text" {
|
|
tokens := tokenkit.Count(model, content.Text)
|
|
textToken += tokens
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case dto.RealtimeEventTypeResponseDone:
|
|
// count tools token
|
|
if !info.IsFirstRequest {
|
|
if info.RealtimeTools != nil && len(info.RealtimeTools) > 0 {
|
|
for _, tool := range info.RealtimeTools {
|
|
toolTokens := CountTokenInput(tool, model)
|
|
textToken += 8
|
|
textToken += toolTokens
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return textToken, audioToken, nil
|
|
}
|
|
|
|
func CountTokenInput(input any, model string) int {
|
|
switch v := input.(type) {
|
|
case string:
|
|
return tokenkit.Count(model, v)
|
|
case []string:
|
|
var text strings.Builder
|
|
for _, s := range v {
|
|
text.WriteString(s)
|
|
}
|
|
return tokenkit.Count(model, text.String())
|
|
case []any:
|
|
var text strings.Builder
|
|
for _, item := range v {
|
|
text.WriteString(fmt.Sprintf("%v", item))
|
|
}
|
|
return tokenkit.Count(model, text.String())
|
|
}
|
|
return CountTokenInput(fmt.Sprintf("%v", input), model)
|
|
}
|
|
|
|
func CountAudioTokenInput(audioBase64 string, audioFormat string) (int, error) {
|
|
if audioBase64 == "" {
|
|
return 0, nil
|
|
}
|
|
duration, err := parseAudio(audioBase64, audioFormat)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// duration 来自用户提供的音频元数据,饱和转换防止 int 回绕
|
|
return common.QuotaFromFloat(duration / 60 * 100 / 0.06), nil
|
|
}
|
|
|
|
func CountAudioTokenOutput(audioBase64 string, audioFormat string) (int, error) {
|
|
if audioBase64 == "" {
|
|
return 0, nil
|
|
}
|
|
duration, err := parseAudio(audioBase64, audioFormat)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// duration 来自上游返回的音频元数据,饱和转换防止 int 回绕
|
|
return common.QuotaFromFloat(duration / 60 * 200 / 0.24), nil
|
|
}
|