mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-30 11:06:56 +00: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.
246 lines
7.3 KiB
Go
246 lines
7.3 KiB
Go
// Package tokenkit estimates LLM token counts for text and images without
|
|
// calling an upstream API.
|
|
//
|
|
// Text is counted exactly with tiktoken for OpenAI GPT and o-series models and
|
|
// estimated with per-tokenizer weights for Claude, Gemini, and other models.
|
|
// Image costs follow each provider's published sizing rules. The weights were
|
|
// calibrated against upstream usage on real agent traffic (Claude Code and
|
|
// Codex sessions).
|
|
package tokenkit
|
|
|
|
import (
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// Family is a tokenizer family. Models in one family tokenize text alike.
|
|
type Family string
|
|
|
|
const (
|
|
FamilyOpenAI Family = "openai" // GPT and o-series models
|
|
FamilyClaude Family = "claude" // Claude models before Claude Opus 4.7
|
|
FamilyClaude47 Family = "claude4.7" // Claude Opus 4.7 and later, including Claude 5, Fable, and Mythos
|
|
FamilyGemini Family = "gemini" // Gemini models
|
|
FamilyUnknown Family = "unknown" // everything else
|
|
)
|
|
|
|
// ModelFamily classifies a model name, case-insensitively. Vendor prefixes and
|
|
// date suffixes are allowed (for example us.anthropic.claude-opus-4-7-v1:0).
|
|
func ModelFamily(model string) Family {
|
|
model = strings.ToLower(model)
|
|
switch {
|
|
case strings.Contains(model, "gemini"):
|
|
return FamilyGemini
|
|
case strings.Contains(model, "claude"):
|
|
if claudeUsesOpus47Tokenizer(model) {
|
|
return FamilyClaude47
|
|
}
|
|
return FamilyClaude
|
|
case isOpenAITextModel(model):
|
|
return FamilyOpenAI
|
|
default:
|
|
return FamilyUnknown
|
|
}
|
|
}
|
|
|
|
func isOpenAITextModel(model string) bool {
|
|
for _, marker := range []string{"gpt-", "o1", "o3", "o4", "chatgpt"} {
|
|
if strings.Contains(model, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// claudeUsesOpus47Tokenizer reports whether a lower-cased Claude model name uses
|
|
// the tokenizer and high-resolution image tier introduced with Claude Opus 4.7.
|
|
// Claude 4.7 and later use them; names without a version are treated as new.
|
|
func claudeUsesOpus47Tokenizer(model string) bool {
|
|
major, minor, ok := modelVersion(model, "claude-")
|
|
if !ok {
|
|
return true
|
|
}
|
|
return major > 4 || (major == 4 && minor >= 7)
|
|
}
|
|
|
|
// modelVersion reads the first one- or two-digit version numbers after prefix,
|
|
// skipping family names (claude-opus-4-7, claude-3-5-sonnet, gemini-3.7-flash).
|
|
// A date such as 20250929 ends the version. ok is false when no version is found.
|
|
func modelVersion(model, prefix string) (major, minor int, ok bool) {
|
|
_, name, found := strings.Cut(model, prefix)
|
|
if !found {
|
|
return 0, 0, false
|
|
}
|
|
version := make([]int, 0, 2)
|
|
for part := range strings.FieldsFuncSeq(name, func(r rune) bool { return r == '-' || r == '.' }) {
|
|
digits := len(part) - len(strings.TrimLeft(part, "0123456789"))
|
|
if digits == 0 || digits > 2 {
|
|
if len(version) > 0 {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
n, _ := strconv.Atoi(part[:digits])
|
|
version = append(version, n)
|
|
if len(version) == 2 || digits < len(part) {
|
|
break
|
|
}
|
|
}
|
|
switch len(version) {
|
|
case 0:
|
|
return 0, 0, false
|
|
case 1:
|
|
return version[0], 0, true
|
|
default:
|
|
return version[0], version[1], true
|
|
}
|
|
}
|
|
|
|
// weights are the estimated tokens per text feature.
|
|
type weights struct {
|
|
Word float64 // Latin word (each run of letters)
|
|
Number float64 // each run of digits
|
|
CJK float64 // each Chinese, Japanese, or Korean character
|
|
Symbol float64 // each ordinary punctuation mark
|
|
MathSymbol float64 // each math symbol (∑, ∫, ∂, √, ...)
|
|
URLDelim float64 // each URL delimiter (/ : ? & = ; # %)
|
|
AtSign float64 // each @, which splits words
|
|
Emoji float64 // each emoji
|
|
Newline float64 // each newline or tab
|
|
Space float64 // each other whitespace character
|
|
}
|
|
|
|
// Word, Number, CJK, Symbol, URLDelim, Newline, and Space are calibrated
|
|
// against upstream counts on agent traffic. MathSymbol, AtSign, and Emoji make
|
|
// up under 0.3% of that traffic and keep their earlier values. FamilyUnknown
|
|
// keeps the earlier OpenAI weights; no other vendor was calibrated.
|
|
var familyWeights = map[Family]weights{
|
|
FamilyGemini: {
|
|
Word: 0.89, Number: 2.42, CJK: 0.93, Symbol: 0.65, MathSymbol: 1.05, URLDelim: 1.3, AtSign: 2.5, Emoji: 1.08, Newline: 1.28, Space: 0,
|
|
},
|
|
FamilyClaude: {
|
|
Word: 1.24, Number: 1.53, CJK: 1.38, Symbol: 0.66, MathSymbol: 4.52, URLDelim: 0.74, AtSign: 2.82, Emoji: 2.6, Newline: 1.19, Space: 0.14,
|
|
},
|
|
FamilyClaude47: {
|
|
Word: 1.52, Number: 1.77, CJK: 1.4, Symbol: 0.89, MathSymbol: 4.52, URLDelim: 1.45, AtSign: 2.82, Emoji: 2.6, Newline: 1.38, Space: 0.21,
|
|
},
|
|
FamilyOpenAI: {
|
|
Word: 0.75, Number: 1.86, CJK: 0.96, Symbol: 0.6, MathSymbol: 2.68, URLDelim: 0.77, AtSign: 2.0, Emoji: 2.12, Newline: 0.95, Space: 0.17,
|
|
},
|
|
FamilyUnknown: {
|
|
Word: 1.02, Number: 1.55, CJK: 0.85, Symbol: 0.4, MathSymbol: 2.68, URLDelim: 1.0, AtSign: 2.0, Emoji: 2.12, Newline: 0.5, Space: 0.42,
|
|
},
|
|
}
|
|
|
|
// Estimate returns the estimated token count of text for model, using the
|
|
// weights of the model's family. It never runs a tokenizer; use Count for an
|
|
// exact count where one is available.
|
|
func Estimate(model, text string) int {
|
|
if text == "" {
|
|
return 0
|
|
}
|
|
return EstimateFamily(ModelFamily(model), text)
|
|
}
|
|
|
|
// EstimateFamily returns the estimated token count of text for a tokenizer
|
|
// family. Unrecognized families use the FamilyUnknown weights.
|
|
func EstimateFamily(family Family, text string) int {
|
|
w, ok := familyWeights[family]
|
|
if !ok {
|
|
w = familyWeights[FamilyUnknown]
|
|
}
|
|
var count float64
|
|
|
|
type wordType int
|
|
const (
|
|
none wordType = iota
|
|
latin
|
|
number
|
|
)
|
|
current := none
|
|
|
|
for _, r := range text {
|
|
if unicode.IsSpace(r) {
|
|
current = none
|
|
if r == '\n' || r == '\t' {
|
|
count += w.Newline
|
|
} else {
|
|
count += w.Space
|
|
}
|
|
continue
|
|
}
|
|
|
|
if isCJK(r) {
|
|
current = none
|
|
count += w.CJK
|
|
continue
|
|
}
|
|
|
|
if isEmoji(r) {
|
|
current = none
|
|
count += w.Emoji
|
|
continue
|
|
}
|
|
|
|
// A run of letters or digits counts once; switching between letters
|
|
// and digits starts a new run.
|
|
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
|
next := latin
|
|
if unicode.IsNumber(r) {
|
|
next = number
|
|
}
|
|
if current != next {
|
|
if next == number {
|
|
count += w.Number
|
|
} else {
|
|
count += w.Word
|
|
}
|
|
current = next
|
|
}
|
|
continue
|
|
}
|
|
|
|
current = none
|
|
switch {
|
|
case isMathSymbol(r):
|
|
count += w.MathSymbol
|
|
case r == '@':
|
|
count += w.AtSign
|
|
case isURLDelim(r):
|
|
count += w.URLDelim
|
|
default:
|
|
count += w.Symbol
|
|
}
|
|
}
|
|
|
|
return int(math.Ceil(count))
|
|
}
|
|
|
|
func isCJK(r rune) bool {
|
|
return unicode.Is(unicode.Han, r) ||
|
|
(r >= 0x3040 && r <= 0x30FF) || // Japanese kana
|
|
(r >= 0xAC00 && r <= 0xD7A3) // Korean Hangul syllables
|
|
}
|
|
|
|
func isEmoji(r rune) bool {
|
|
return (r >= 0x1F300 && r <= 0x1F9FF) || // pictographs, emoticons, supplemental symbols
|
|
(r >= 0x2600 && r <= 0x26FF) || // miscellaneous symbols
|
|
(r >= 0x2700 && r <= 0x27BF) || // dingbats
|
|
(r >= 0x1FA00 && r <= 0x1FAFF) // symbols and pictographs extended-A
|
|
}
|
|
|
|
func isMathSymbol(r rune) bool {
|
|
if strings.ContainsRune("∑∫∂√∞≤≥≠≈±×÷∈∉∋∌⊂⊃⊆⊇∪∩∧∨¬∀∃∄∅∆∇∝∟∠∡∢°′″‴⁺⁻⁼⁽⁾ⁿ₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎²³¹⁴⁵⁶⁷⁸⁹⁰", r) {
|
|
return true
|
|
}
|
|
return (r >= 0x2200 && r <= 0x22FF) || // mathematical operators
|
|
(r >= 0x2A00 && r <= 0x2AFF) || // supplemental mathematical operators
|
|
(r >= 0x1D400 && r <= 0x1D7FF) // mathematical alphanumeric symbols
|
|
}
|
|
|
|
func isURLDelim(r rune) bool {
|
|
return strings.ContainsRune("/:?&=;#%", r)
|
|
}
|