mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +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.
291 lines
10 KiB
Go
291 lines
10 KiB
Go
package tokenkit
|
||
|
||
import (
|
||
"math"
|
||
"strings"
|
||
)
|
||
|
||
// Image describes one input image.
|
||
type Image struct {
|
||
// Width and Height are the pixel dimensions. Zero or negative means
|
||
// unknown; where size matters, a 1024×1024 image is assumed.
|
||
Width, Height int
|
||
// Detail is the OpenAI detail level (low, high, auto, original) or the
|
||
// Gemini media resolution (MEDIA_RESOLUTION_LOW, _MEDIUM, _HIGH,
|
||
// _ULTRA_HIGH). Empty means the provider default. Each family ignores
|
||
// values meant for the other.
|
||
Detail string
|
||
}
|
||
|
||
const (
|
||
unknownImageSide = 1024
|
||
// Larger sides are scaled down first; OpenAI does the same for original
|
||
// detail and every other provider rejects such images.
|
||
maxImageSide = 65535
|
||
// OpenAI rejects images that need more patches, so no estimate exceeds it.
|
||
maxImagePatches = 30000
|
||
// Earlier flat estimate, kept for models whose sizing rules are unknown.
|
||
unknownFamilyImageTokens = 520
|
||
)
|
||
|
||
// ImageTokens returns the prompt tokens one image costs on model.
|
||
func ImageTokens(model string, img Image) int {
|
||
w, h := img.Width, img.Height
|
||
if w <= 0 || h <= 0 {
|
||
w, h = unknownImageSide, unknownImageSide
|
||
}
|
||
if w > maxImageSide || h > maxImageSide {
|
||
w, h = fitWithin(w, h, maxImageSide)
|
||
}
|
||
|
||
switch ModelFamily(model) {
|
||
case FamilyOpenAI:
|
||
return openAIImageRuleFor(model, img.Detail).tokens(w, h)
|
||
case FamilyClaude:
|
||
// Standard tier; each image adds 4 tokens of framing (measured).
|
||
w, h = claudeResizedSize(w, h, 1568, 1568)
|
||
return ceilDiv(w, 28)*ceilDiv(h, 28) + 4
|
||
case FamilyClaude47:
|
||
// High-resolution tier; each image adds 3 tokens of framing (measured).
|
||
w, h = claudeResizedSize(w, h, 2576, 4784)
|
||
return ceilDiv(w, 28)*ceilDiv(h, 28) + 3
|
||
case FamilyGemini:
|
||
if geminiUsesMediaResolution(model) {
|
||
return geminiMediaResolutionTokens(img.Detail)
|
||
}
|
||
return geminiTileImageTokens(w, h)
|
||
default:
|
||
return unknownFamilyImageTokens
|
||
}
|
||
}
|
||
|
||
// ImageNeedsSize reports whether ImageTokens depends on the image dimensions
|
||
// for model and detail. When it returns false, callers can skip reading the
|
||
// image.
|
||
func ImageNeedsSize(model, detail string) bool {
|
||
switch ModelFamily(model) {
|
||
case FamilyOpenAI:
|
||
rule := openAIImageRuleFor(model, detail)
|
||
return rule.tileTokens == 0 || !rule.lowDetail
|
||
case FamilyClaude, FamilyClaude47:
|
||
return true
|
||
case FamilyGemini:
|
||
return !geminiUsesMediaResolution(model)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// openAIImageRule is the sizing rule for one OpenAI model and detail level,
|
||
// from https://developers.openai.com/api/docs/guides/images-vision and
|
||
// measured upstream usage.
|
||
type openAIImageRule struct {
|
||
// Tile-based models cost baseTokens plus tileTokens per 512px tile.
|
||
baseTokens, tileTokens int
|
||
lowDetail bool
|
||
|
||
// Patch-based models cost 32px patches times multiplier. The multiplier
|
||
// is a float32 because upstream bills that way: 1225 patches × 1.2 bills
|
||
// 1471, not 1470.
|
||
multiplier float32
|
||
maxSide int // fit within maxSide × maxSide
|
||
shortSide int // scale the shortest side down to shortSide
|
||
budget int // resizing patch budget
|
||
}
|
||
|
||
func openAIImageRuleFor(model, detail string) openAIImageRule {
|
||
model = strings.ToLower(model)
|
||
detail = strings.ToLower(detail)
|
||
switch detail {
|
||
case "low", "high", "original":
|
||
default:
|
||
detail = "auto"
|
||
}
|
||
major, minor, isGPT := modelVersion(model, "gpt-")
|
||
|
||
switch {
|
||
case isGPT && major >= 6:
|
||
// gpt-6-astra: auto means original. Upstream fits high within 2048
|
||
// before the 2,500-patch budget; the documentation omits the 2048 limit.
|
||
switch detail {
|
||
case "low":
|
||
return openAIImageRule{multiplier: 1.2, maxSide: 512}
|
||
case "high":
|
||
return openAIImageRule{multiplier: 1.2, maxSide: 2048, budget: 2500}
|
||
default:
|
||
return openAIImageRule{multiplier: 1.2}
|
||
}
|
||
case isGPT && major == 5 && minor >= 5:
|
||
// gpt-5.5 and gpt-5.6: auto means original. Upstream bills high as
|
||
// fit 2048 then shortest side 768, not the documented 2,500-patch budget.
|
||
switch detail {
|
||
case "low":
|
||
return openAIImageRule{multiplier: 1.2, maxSide: 512}
|
||
case "high":
|
||
return openAIImageRule{multiplier: 1.2, maxSide: 2048, shortSide: 768}
|
||
}
|
||
if minor == 5 {
|
||
return openAIImageRule{multiplier: 1.2, maxSide: 6000, budget: 10000}
|
||
}
|
||
return openAIImageRule{multiplier: 1.2}
|
||
case isGPT && major == 5 && minor == 4:
|
||
// gpt-5.4: auto means high.
|
||
switch detail {
|
||
case "low":
|
||
return openAIImageRule{multiplier: 1.2, maxSide: 2048, budget: 6144}
|
||
case "original":
|
||
return openAIImageRule{multiplier: 1.2, maxSide: 6000, budget: 10000}
|
||
default:
|
||
return openAIImageRule{multiplier: 1.2, maxSide: 2048, budget: 2500}
|
||
}
|
||
case isGPT && major == 5 && minor >= 2:
|
||
return openAIImageRule{multiplier: 1.2, maxSide: 2048, budget: 6144}
|
||
case isGPT && major == 5 && minor == 0 && strings.Contains(model, "nano"):
|
||
return openAIImageRule{multiplier: 1.5, budget: 1536}
|
||
case isGPT && major == 5 && minor == 0 && strings.Contains(model, "mini"):
|
||
return openAIImageRule{multiplier: 1.2, budget: 1536}
|
||
case isGPT && major == 5:
|
||
return openAIImageRule{baseTokens: 70, tileTokens: 140, lowDetail: detail == "low"}
|
||
case isGPT && major == 4 && minor == 1 && strings.Contains(model, "mini"):
|
||
return openAIImageRule{multiplier: 1.62, maxSide: 2048, budget: 6144}
|
||
case isGPT && major == 4 && minor == 1 && strings.Contains(model, "nano"):
|
||
return openAIImageRule{multiplier: 2.46, budget: 1536}
|
||
case strings.Contains(model, "4o-mini"):
|
||
return openAIImageRule{baseTokens: 2833, tileTokens: 5667, lowDetail: detail == "low"}
|
||
case strings.Contains(model, "o4-mini"):
|
||
return openAIImageRule{multiplier: 1.72, budget: 1536}
|
||
case !isGPT && (strings.Contains(model, "o1") || strings.Contains(model, "o3")):
|
||
return openAIImageRule{baseTokens: 75, tileTokens: 150, lowDetail: detail == "low"}
|
||
default:
|
||
// gpt-4o, gpt-4.1, gpt-4.5, and older vision models.
|
||
return openAIImageRule{baseTokens: 85, tileTokens: 170, lowDetail: detail == "low"}
|
||
}
|
||
}
|
||
|
||
func (r openAIImageRule) tokens(w, h int) int {
|
||
if r.tileTokens > 0 {
|
||
if r.lowDetail {
|
||
return r.baseTokens
|
||
}
|
||
w, h = fitWithin(w, h, 2048)
|
||
w, h = shrinkShortSide(w, h, 768)
|
||
return r.baseTokens + r.tileTokens*ceilDiv(w, 512)*ceilDiv(h, 512)
|
||
}
|
||
|
||
if r.maxSide > 0 {
|
||
w, h = fitWithin(w, h, r.maxSide)
|
||
}
|
||
patches := ceilDiv(w, 32) * ceilDiv(h, 32)
|
||
if short, long := min(w, h), max(w, h); r.shortSide > 0 && short > r.shortSide {
|
||
// Upstream keeps the long side unrounded here: 3000x2000 fits to
|
||
// 2048x1365, and 2048 x 768/1365 = 1152.3 needs 37 patch columns.
|
||
patches = ceilDiv(r.shortSide, 32) * ceilDiv(long*r.shortSide, short*32)
|
||
}
|
||
if r.budget > 0 && patches > r.budget {
|
||
shrink := math.Sqrt(float64(32*32*r.budget) / (float64(w) * float64(h)))
|
||
scaledW, scaledH := float64(w)*shrink/32, float64(h)*shrink/32
|
||
shrink *= min(math.Floor(scaledW)/scaledW, math.Floor(scaledH)/scaledH)
|
||
w, h = max(int(float64(w)*shrink), 1), max(int(float64(h)*shrink), 1)
|
||
patches = ceilDiv(w, 32) * ceilDiv(h, 32)
|
||
}
|
||
patches = min(patches, maxImagePatches)
|
||
return int(math.Ceil(float64(patches) * float64(r.multiplier)))
|
||
}
|
||
|
||
// claudeResizedSize is the size Claude scales an image to: the largest
|
||
// aspect-preserving size whose padded sides fit maxEdge and whose 28px patches
|
||
// fit maxTokens. Reference implementation:
|
||
// https://platform.claude.com/docs/en/build-with-claude/vision-coordinates
|
||
func claudeResizedSize(width, height, maxEdge, maxTokens int) (int, int) {
|
||
if claudeImageFits(width, height, maxEdge, maxTokens) {
|
||
return width, height
|
||
}
|
||
if height > width {
|
||
resizedH, resizedW := claudeResizedSize(height, width, maxEdge, maxTokens)
|
||
return resizedW, resizedH
|
||
}
|
||
aspectRatio := float64(width) / float64(height)
|
||
lo, hi := 1, width // lo always fits; hi never fits
|
||
for lo+1 < hi {
|
||
mid := (lo + hi) / 2
|
||
if claudeImageFits(mid, max(int(math.RoundToEven(float64(mid)/aspectRatio)), 1), maxEdge, maxTokens) {
|
||
lo = mid
|
||
} else {
|
||
hi = mid
|
||
}
|
||
}
|
||
return lo, max(int(math.RoundToEven(float64(lo)/aspectRatio)), 1)
|
||
}
|
||
|
||
func claudeImageFits(w, h, maxEdge, maxTokens int) bool {
|
||
return ceilDiv(w, 28)*28 <= maxEdge && ceilDiv(h, 28)*28 <= maxEdge && ceilDiv(w, 28)*ceilDiv(h, 28) <= maxTokens
|
||
}
|
||
|
||
// geminiUsesMediaResolution reports whether a lower-cased Gemini model bills
|
||
// images by media resolution regardless of size (Gemini 3 and later, and names
|
||
// without a version).
|
||
func geminiUsesMediaResolution(model string) bool {
|
||
major, _, ok := modelVersion(strings.ToLower(model), "gemini-")
|
||
return !ok || major >= 3
|
||
}
|
||
|
||
// geminiMediaResolutionTokens returns the documented per-image cost for a
|
||
// Gemini media resolution. Measured costs run 2-9% lower depending on aspect.
|
||
func geminiMediaResolutionTokens(detail string) int {
|
||
level, ok := strings.CutPrefix(strings.ToLower(detail), "media_resolution_")
|
||
if !ok {
|
||
return 1120
|
||
}
|
||
switch level {
|
||
case "low":
|
||
return 280
|
||
case "medium":
|
||
return 560
|
||
case "ultra_high":
|
||
return 2240
|
||
default:
|
||
return 1120
|
||
}
|
||
}
|
||
|
||
// geminiTileImageTokens follows the Gemini 2.x rule measured on gemini-2.5-flash:
|
||
// images up to 384px on both sides cost one 258-token tile; larger images are
|
||
// cut into square tiles of a third less than the shortest side (256 to 768px),
|
||
// at most 12, plus one thumbnail tile. 4K images bill below this cap.
|
||
func geminiTileImageTokens(w, h int) int {
|
||
if w <= 384 && h <= 384 {
|
||
return 258
|
||
}
|
||
tile := min(max(min(w, h)*2/3, 256), 768)
|
||
tiles := min(ceilDiv(w, tile)*ceilDiv(h, tile), 12)
|
||
return (tiles + 1) * 258
|
||
}
|
||
|
||
// fitWithin scales an image down so neither side exceeds limit, rounding the
|
||
// shorter side down. Smaller images are not enlarged.
|
||
func fitWithin(w, h, limit int) (int, int) {
|
||
if w <= limit && h <= limit {
|
||
return w, h
|
||
}
|
||
if w >= h {
|
||
return limit, max(int(int64(h)*int64(limit)/int64(w)), 1)
|
||
}
|
||
return max(int(int64(w)*int64(limit)/int64(h)), 1), limit
|
||
}
|
||
|
||
// shrinkShortSide scales an image down so its shortest side is at most limit,
|
||
// rounding the other side down.
|
||
func shrinkShortSide(w, h, limit int) (int, int) {
|
||
if min(w, h) <= limit {
|
||
return w, h
|
||
}
|
||
if w <= h {
|
||
return limit, max(int(int64(h)*int64(limit)/int64(w)), 1)
|
||
}
|
||
return max(int(int64(w)*int64(limit)/int64(h)), 1), limit
|
||
}
|
||
|
||
func ceilDiv(a, b int) int {
|
||
return (a + b - 1) / b
|
||
}
|