feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
# TokenKit
TokenKit 是从 [new-api](https://github.com/QuantumNous/new-api) 中拆分出的独立 Go 模块,用来在不调用上游 API 的情况下,计算或估算大模型请求的 token 数。
它只做计数,不负责解析请求、下载图片或计费。调用方传入文本,或者图片的尺寸和 detail 设置,TokenKit 返回 token 数。可以脱离 new-api 主模块,嵌入其他 Go 网关、代理或客户端。
## 能力
- GPT 和 o 系列模型用 tiktoken 精确计数,GPT-4 和 GPT-3.5 走 `cl100k_base`,其余走 `o200k_base`
- Claude、Gemini 和其他模型按字符类别加权估算,Claude Opus 4.7 前后的两套 tokenizer 分开处理
- 按各家公开的尺寸规则计算图片 token,包括 OpenAI 的 detail 级别和 Gemini 3 的 media resolution
- 不依赖 new-api 主模块、Gin、数据库或全局设置
## 准确度
文本权重是用真实 agent 会话校准的:从 Claude Code 和 Codex 的会话里取了 54 段,包括工具定义、工具调用和工具输出,和上游返回的 usage 逐条对比。
| 模型 | 测试模型 | prompt 偏差 / 平均误差 | 输出文本 偏差 / 平均误差 |
|---|---|---|---|
| Claude(4.7 之前) | claude-haiku-4-5 | +0.5% / 3.3% | −0.6% / 3.8% |
| Claude(4.7 及以后) | claude-sonnet-5 | +0.3% / 3.5% | −1.1% / 4.3% |
| GPT(估算) | gpt-5.6-luna | +0.9% / 2.9% | −1.8% / 5.3% |
| Gemini | gemini-3.7-flash | +0.7% / 4.2% | −1.3% / 4.7% |
GPT 用 `Count` 时和 tiktoken 基本一致。有两处已知差异:
- 只含缩进空格的空行会多算 1 个 token,来自 tiktoken-go v0.8.1 的分词正则,1.2 MB 的 agent 文本上一共多 3 个。
- 超过 1024 字节、中间没有分隔的连续片段(例如没有标点的长段汉字、长串空格)会被切开再计数,误差不到 0.5%。不切的话,BPE 的耗时随片段长度平方增长:10 万个连续汉字要 20 秒左右,切开后约 0.1 秒。
估算适合 agent 流量:代码、JSON、命令输出和中英文混排。几百 token 的短文本误差会大一些。
图片规则和上游实测一致:OpenAI 的 patch 和 tile 计算、Claude 两个分辨率档位、Gemini 2.x 的切块。例外和注意事项见[图片](#图片)。
## 安装
TokenKit 要求 Go 1.26 或更高版本。
```bash
go get github.com/QuantumNous/new-api/tokenkit@latest
```
## 快速开始
```go
package main
import (
"fmt"
"github.com/QuantumNous/new-api/tokenkit"
)
func main() {
prompt := "读取 main.go 并解释 init 函数"
// GPT 用 tiktoken 精确计数,其他模型估算
fmt.Println(tokenkit.Count("gpt-5.6-sol", prompt))
fmt.Println(tokenkit.Count("claude-opus-5-5", prompt))
// 只要估算,不跑 tokenizer,比如流式输出中途断开时补算 completion
fmt.Println(tokenkit.Estimate("gpt-5.6-sol", prompt))
// 一张 1920×1080 的截图
shot := tokenkit.Image{Width: 1920, Height: 1080}
fmt.Println(tokenkit.ImageTokens("claude-opus-5-5", shot)) // 2694
fmt.Println(tokenkit.ImageTokens("claude-haiku-4-5", shot)) // 1564
shot.Detail = "low"
fmt.Println(tokenkit.ImageTokens("gpt-5.6-sol", shot)) // 173
}
```
## 模型分组
`ModelFamily` 按模型名分组,不区分大小写,允许带厂商前缀和日期后缀,例如 `us.anthropic.claude-opus-4-7-v1:0`、`claude-sonnet-4-5-20250929`。
| 分组 | 匹配 | 文本 |
|---|---|---|
| `FamilyOpenAI` | 名称含 `gpt-`、`o1`、`o3`、`o4`、`chatgpt` | `Count` 精确,`Estimate` 估算 |
| `FamilyClaude` | Claude 4.7 之前,例如 Haiku 4.5、Sonnet 4.6 | 估算 |
| `FamilyClaude47` | Claude 4.7 及以后,以及读不出版本号的名称,例如 Opus 4.7、Sonnet 5、Fable、Mythos | 估算 |
| `FamilyGemini` | 名称含 `gemini` | 估算 |
| `FamilyUnknown` | 其他模型 | 估算,权重未校准 |
Claude 4.7 及以后的 tokenizer 对同样的文本多出约 30% 的 token,所以两组 Claude 用不同的权重。
## 图片
`ImageTokens(model, Image)` 返回一张图片占用的 prompt token。`Image.Width` 和 `Image.Height` 是像素尺寸,为 0 时按 1024×1024 计算。`ImageNeedsSize` 判断某个模型和 detail 设置下是否需要尺寸;返回 false 时可以不读取图片。
| 模型 | 计算方式 |
|---|---|
| gpt-5.2 及以后、gpt-6 | 32px patch × 1.2,按模型和 detail 缩放;不传 detail 时 gpt-5.5 及以后按 `original` 计算 |
| gpt-5、gpt-5.1、gpt-4o、gpt-4.1、o1、o3 | 512px tile,加 base tokens |
| Claude 4.7 之前 | ⌈宽/28⌉ × ⌈高/28⌉,最长边 1568,最多 1568 个 patch,另加 4 |
| Claude 4.7 及以后 | 同上,最长边 2576,最多 4784 个 patch,另加 3 |
| Gemini 3 | 与尺寸无关,按 media resolution:280 / 560 / 1120 / 2240,默认 1120 |
| Gemini 2.x | 两边都不超过 384 时 258;更大的图切成方块,每块 258,最多 12 块,另加 1 块缩略图 |
| 其他模型 | 每张 520 |
`Image.Detail` 填 OpenAI 的 detail(`low`、`high`、`auto`、`original`),或 Gemini 的 media resolution(`MEDIA_RESOLUTION_LOW` 等)。两类值只在对应的模型上生效,例如 Gemini 会忽略 `low`。
注意事项:
- 有两处按上游实际计费,而不是按官方文档。gpt-5.5 和 gpt-5.6 的 `high` 先缩到 2048×2048 以内,最短边再缩到 768,文档写的是 2500 patch 预算。gpt-6 的 `high` 先缩到 2048×2048 以内,再按 2500 patch 预算缩放,文档没有写 2048 这一步。
- Gemini 3 用的是文档值。实测按图片宽高比低 2% 到 9%。
- Gemini 2.x 的 4K 图会多算:3840×2160 实际 2322,这里给 3354。
- 图片尺寸来自请求方,可能被伪造。超过 65535 像素的边会先缩小,单张最多按 30000 个 patch 计算,结果始终有上限。
## 开发
TokenKit 必须始终保持独立可构建,不依赖 new-api 主模块或 RelayKit。修改模块后,在 `tokenkit` 目录运行:
```bash
GOWORK=off go test ./...
GOWORK=off go build ./...
```
`image_test.go` 里的期望值是上游实测值。改动图片规则时,用同样的方法重新测量:同一个请求带图和不带图各发一次,两次 input token 的差就是这张图的消耗。
## 版本与兼容性
TokenKit 当前使用 `v0.x` 版本,公开 API 可能在小版本中调整。估算权重和图片规则会随上游模型更新重新校准,同样的输入在不同版本之间可能得到不同的结果。
## 许可证
TokenKit 是 new-api 项目的一部分,遵循项目根目录中的 [GNU Affero General Public License v3.0](../LICENSE)。
+15
View File
@@ -0,0 +1,15 @@
module github.com/QuantumNous/new-api/tokenkit
go 1.26
require (
github.com/stretchr/testify v1.11.1
github.com/tiktoken-go/tokenizer v0.8.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+14
View File
@@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
github.com/dlclark/regexp2/v2 v2.5.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiktoken-go/tokenizer v0.8.1 h1:4obDoB6/dhdBt9xMweX4nww5cjdOq/nYF4ecwPq2+mg=
github.com/tiktoken-go/tokenizer v0.8.1/go.mod h1:eLA0t6nGvn9mDc7gt90qt7pMat+gE9ViqwQ6l9B+tA4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+290
View File
@@ -0,0 +1,290 @@
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
}
+124
View File
@@ -0,0 +1,124 @@
package tokenkit
import (
"testing"
"github.com/stretchr/testify/assert"
)
// Unless marked otherwise, want is the per-image prompt-token cost measured
// through upstream usage (request with the image minus the same request
// without it) on 2026-10-07.
func TestImageTokensMatchUpstreamUsage(t *testing.T) {
for _, tc := range []struct {
model string
w, h int
detail string
want int
}{
// gpt-5.6: auto means original; high scales the shortest side to 768.
{"gpt-5.6-luna", 200, 200, "", 59},
{"gpt-5.6-luna", 1000, 1000, "", 1229},
{"gpt-5.6-luna", 1000, 1000, "auto", 1229},
{"gpt-5.6-luna", 1000, 1000, "original", 1229},
{"gpt-5.6-luna", 1000, 1000, "high", 692},
{"gpt-5.6-luna", 1000, 1000, "low", 308},
{"gpt-5.6-luna", 200, 200, "high", 59},
{"gpt-5.6-luna", 2048, 2048, "", 4916},
{"gpt-5.6-luna", 2048, 2048, "high", 692},
{"gpt-5.6-luna", 3840, 2160, "", 9793},
{"gpt-5.6-luna", 3840, 2160, "high", 1239},
{"gpt-5.6-luna", 3840, 2160, "low", 173},
{"gpt-5.6-luna", 600, 3000, "", 2144},
{"gpt-5.6-luna", 600, 3000, "high", 999},
{"gpt-5.6-luna", 600, 3000, "low", 77},
{"gpt-5.6-luna", 8000, 500, "", 4801},
{"gpt-5.6-luna", 8000, 500, "high", 308},
{"gpt-5.6-luna", 8000, 500, "low", 20},
{"gpt-5.6-luna", 3000, 2000, "high", 1066},
{"gpt-5.6-luna", 1170, 2532, "high", 1498},
{"gpt-5.6-luna", 1600, 1200, "high", 922},
// gpt-5.5 original is limited to 6000px and 10,000 patches.
{"gpt-5.5", 8000, 500, "", 2708},
{"gpt-5.5", 3840, 2160, "", 9793},
{"gpt-5.5", 2048, 2048, "high", 692},
// gpt-6-astra high fits within 2048, then applies a 2,500-patch budget.
{"gpt-6-astra", 1024, 1024, "high", 1229},
{"gpt-6-astra", 2048, 2048, "high", 3001},
{"gpt-6-astra", 3840, 2160, "high", 2765},
{"gpt-6-astra", 1170, 2532, "high", 2305},
{"gpt-6-astra", 3000, 2000, "high", 2881},
{"gpt-6-astra", 2048, 2048, "", 4916},
{"gpt-6-astra", 3840, 2160, "", 9793},
{"gpt-6-astra", 600, 3000, "", 2144},
{"gpt-6-astra", 3840, 2160, "low", 173},
// Tile-based models follow the documented formula.
{"gpt-4o", 1000, 1000, "", 85 + 170*4},
{"gpt-4o", 1000, 1000, "low", 85},
{"gpt-5.1", 200, 200, "high", 70 + 140},
// Claude: ceil(w/28) x ceil(h/28) after the tier resize, plus framing.
{"claude-haiku-4-5", 200, 200, "", 68},
{"claude-haiku-4-5", 1000, 1000, "", 1300},
{"claude-haiku-4-5", 1092, 1092, "", 1525},
{"claude-haiku-4-5", 1920, 1080, "", 1564},
{"claude-haiku-4-5", 1075, 1520, "", 1555},
{"claude-haiku-4-5", 2000, 1500, "", 1568},
{"claude-haiku-4-5", 3840, 2160, "", 1564},
{"claude-haiku-4-5", 4096, 512, "", 396},
{"claude-sonnet-5", 200, 200, "", 67},
{"claude-sonnet-5", 1000, 1000, "", 1299},
{"claude-sonnet-5", 1092, 1092, "", 1524},
{"claude-sonnet-5", 1920, 1080, "", 2694},
{"claude-sonnet-5", 1075, 1520, "", 2148},
{"claude-sonnet-5", 2000, 1500, "", 3891},
{"claude-sonnet-5", 3840, 2160, "", 4787},
{"claude-sonnet-5", 4096, 512, "", 1107},
// Gemini 2.x tiles.
{"gemini-2.5-flash", 384, 384, "", 258},
{"gemini-2.5-flash", 385, 385, "", 1290},
{"gemini-2.5-flash", 769, 769, "", 1290},
{"gemini-2.5-flash", 1536, 768, "", 1806},
{"gemini-2.5-flash", 1537, 768, "", 2322},
{"gemini-2.5-flash", 1920, 1080, "", 1806},
{"gemini-2.5-flash", 2000, 2000, "", 2580},
{"gemini-2.5-flash", 2304, 768, "", 2838},
{"gemini-2.5-flash", 3000, 1000, "", 2838},
{"gemini-2.5-flash", 2500, 2500, "", 3354},
{"gemini-2.5-flash", 4000, 3000, "", 3354},
// Gemini 3 bills by media resolution (documented values; measured 2-9% lower).
// An OpenAI detail level is not a Gemini media resolution.
{"gemini-3.7-flash", 1000, 1000, "", 1120},
{"gemini-3.7-flash", 1000, 1000, "MEDIA_RESOLUTION_LOW", 280},
{"gemini-3.7-flash", 1000, 1000, "media_resolution_medium", 560},
{"gemini-3.7-flash", 3840, 2160, "MEDIA_RESOLUTION_ULTRA_HIGH", 2240},
{"gemini-3.7-flash", 1000, 1000, "low", 1120},
// Unknown sizes assume 1024x1024.
{"gpt-5.6-sol", 0, 0, "", 1229},
{"claude-opus-4-7", 0, 0, "", 37*37 + 3},
{"gemini-2.5-flash", -1, 0, "", 1290},
{"deepseek-v4", 1000, 1000, "", 520},
} {
got := ImageTokens(tc.model, Image{Width: tc.w, Height: tc.h, Detail: tc.detail})
assert.Equal(t, tc.want, got, "%s %dx%d detail=%q", tc.model, tc.w, tc.h, tc.detail)
}
}
// Image dimensions come from client-supplied headers; the cost stays bounded.
func TestImageTokensBoundForgedSizes(t *testing.T) {
const huge = 1 << 30
assert.Equal(t, 36001, ImageTokens("gpt-5.6-sol", Image{Width: huge, Height: huge}))
// The largest square within the visual-token budget: 39x39 and 69x69 patches.
assert.Equal(t, 39*39+4, ImageTokens("claude-sonnet-4-6", Image{Width: huge, Height: huge}))
assert.Equal(t, 69*69+3, ImageTokens("claude-opus-5-5", Image{Width: huge, Height: huge}))
assert.Equal(t, 13*258, ImageTokens("gemini-2.5-pro", Image{Width: huge, Height: huge}))
}
func TestImageNeedsSize(t *testing.T) {
assert.True(t, ImageNeedsSize("gpt-5.6-sol", "low"))
assert.True(t, ImageNeedsSize("gpt-4o", "high"))
assert.False(t, ImageNeedsSize("gpt-4o", "low"))
assert.True(t, ImageNeedsSize("claude-haiku-4-5", ""))
assert.True(t, ImageNeedsSize("gemini-2.5-flash", ""))
assert.False(t, ImageNeedsSize("gemini-3.7-flash", ""))
assert.False(t, ImageNeedsSize("deepseek-v4", ""))
}
+102
View File
@@ -0,0 +1,102 @@
package tokenkit
import (
"strings"
"sync"
"unicode"
"unicode/utf8"
"github.com/tiktoken-go/tokenizer"
"github.com/tiktoken-go/tokenizer/codec"
)
var (
o200kBase = sync.OnceValue(func() tokenizer.Codec { return codec.NewO200kBase() })
cl100kBase = sync.OnceValue(func() tokenizer.Codec { return codec.NewCl100kBase() })
)
// maxRunBytes bounds a run of letters, whitespace, or symbols passed to the
// encoder in one piece. BPE cost grows with the square of a pre-tokenized
// piece: 100,000 Chinese characters without punctuation take about 20 s in one
// piece. Cutting longer runs keeps counting linear; ordinary text has no such
// runs, and a cut changes the count only inside the run (under 0.5% for CJK).
const maxRunBytes = 1024
// Count returns the token count of text for model. GPT and o-series models
// are counted with their tiktoken encoding; other models use Estimate.
func Count(model, text string) int {
if text == "" {
return 0
}
model = strings.ToLower(model)
if ModelFamily(model) != FamilyOpenAI {
return Estimate(model, text)
}
encoder := o200kBase()
if usesCl100k(model) {
encoder = cl100kBase()
}
total, start := 0, 0
runClass, runStart := runNone, 0
for i, r := range text {
class := runClassOf(r)
if class != runClass {
runClass, runStart = class, i
continue
}
if class != runNumber && i-runStart >= maxRunBytes {
n, _ := encoder.Count(text[start:i])
total += n
start, runStart = i, i
}
}
n, _ := encoder.Count(text[start:])
return total + n
}
type runClass int
const (
runNone runClass = iota
runLetter
runNumber
runSpace
runSymbol
)
// runClassOf groups runes the way tiktoken's pre-tokenizer joins them into
// pieces. Digits are split into groups of three by the encoder itself.
func runClassOf(r rune) runClass {
if r < utf8.RuneSelf {
switch {
case 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z':
return runLetter
case '0' <= r && r <= '9':
return runNumber
case r == ' ' || '\t' <= r && r <= '\r':
return runSpace
default:
return runSymbol
}
}
switch {
case unicode.IsLetter(r) || unicode.IsMark(r):
return runLetter
case unicode.IsNumber(r):
return runNumber
case unicode.IsSpace(r):
return runSpace
default:
return runSymbol
}
}
// usesCl100k reports whether an OpenAI model predates o200k_base: GPT-4 (but
// not GPT-4o, GPT-4.1, or GPT-4.5) and GPT-3.5. Every later model, including
// GPT-5, GPT-6, and the o-series, uses o200k_base.
func usesCl100k(model string) bool {
model = strings.TrimPrefix(model, "ft:")
return model == "gpt-4" || strings.HasPrefix(model, "gpt-4-") ||
strings.HasPrefix(model, "gpt-3.5") || strings.HasPrefix(model, "gpt-35")
}
+245
View File
@@ -0,0 +1,245 @@
// 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)
}
+83
View File
@@ -0,0 +1,83 @@
package tokenkit
import (
"math/rand"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tiktoken-go/tokenizer/codec"
)
func TestModelFamily(t *testing.T) {
for _, tc := range []struct {
model string
family Family
}{
{"claude-3-5-sonnet-20240620", FamilyClaude},
{"claude-sonnet-4-20250514", FamilyClaude},
{"claude-sonnet-4-5-20250929", FamilyClaude},
{"claude-haiku-4-5", FamilyClaude},
{"claude-sonnet-4-6", FamilyClaude},
// Claude Opus 4.7 introduced the tokenizer every later Claude model uses.
{"claude-opus-4-7", FamilyClaude47},
{"claude-opus-4-7@20260416", FamilyClaude47},
{"us.anthropic.claude-opus-4-7-v1:0", FamilyClaude47},
{"claude-sonnet-5", FamilyClaude47},
{"claude-fable-5-1", FamilyClaude47},
{"Claude-Opus-5-5", FamilyClaude47},
{"claude-mythos-preview", FamilyClaude47},
{"gemini-3.7-flash", FamilyGemini},
{"gpt-5.6-sol", FamilyOpenAI},
{"gpt-6-astra", FamilyOpenAI},
{"o3-mini", FamilyOpenAI},
{"deepseek-v4", FamilyUnknown},
} {
assert.Equal(t, tc.family, ModelFamily(tc.model), tc.model)
}
}
func TestEstimateUsesFamilyWeights(t *testing.T) {
const text = "func main() {\n\tfmt.Println(\"统计 token\", 42)\n}\nSee https://example.com/a?b=1"
require.NotEqual(t, EstimateFamily(FamilyClaude, text), EstimateFamily(FamilyClaude47, text))
assert.Equal(t, EstimateFamily(FamilyClaude, text), Estimate("claude-haiku-4-5", text))
assert.Equal(t, EstimateFamily(FamilyClaude47, text), Estimate("claude-sonnet-5", text))
assert.Equal(t, EstimateFamily(FamilyUnknown, text), Estimate("deepseek-v4", text))
assert.Equal(t, EstimateFamily(FamilyUnknown, text), EstimateFamily("other", text))
assert.Zero(t, Estimate("claude-sonnet-5", ""))
}
func TestCountUsesTiktokenForOpenAIModels(t *testing.T) {
const text = "大语言模型按 token 计费;Large language models bill by token. 🙂"
o200k, _ := codec.NewO200kBase().Count(text)
cl100k, _ := codec.NewCl100kBase().Count(text)
require.NotEqual(t, o200k, cl100k)
assert.Equal(t, o200k, Count("gpt-5.6-sol", text))
assert.Equal(t, o200k, Count("gpt-6-astra", text))
assert.Equal(t, o200k, Count("gpt-4o-mini", text))
assert.Equal(t, cl100k, Count("gpt-4", text))
assert.Equal(t, cl100k, Count("gpt-3.5-turbo", text))
assert.Equal(t, Estimate("claude-sonnet-5", text), Count("claude-sonnet-5", text))
}
// Long runs without separators are cut before encoding so BPE stays linear;
// ordinary text is counted exactly and cut runs stay within 0.5%.
func TestCountCutsOnlyLongRuns(t *testing.T) {
encoder := codec.NewO200kBase()
ordinary := strings.Repeat("func main() {\n\tfmt.Println(\"统计 token,按模型计费。\", 42)\n}\n| a | b |\n|---|---|\n", 200)
want, _ := encoder.Count(ordinary)
assert.Equal(t, want, Count("gpt-5.6-sol", ordinary))
rng := rand.New(rand.NewSource(1))
var cjk strings.Builder
for range 5000 {
cjk.WriteRune(rune(0x4E00 + rng.Intn(0x9FA5-0x4E00)))
}
for _, text := range []string{cjk.String(), strings.Repeat("token", 2000), "a" + strings.Repeat(" ", 10000) + "b", strings.Repeat("=", 10000)} {
exact, _ := encoder.Count(text)
assert.InEpsilon(t, exact, Count("gpt-5.6-sol", text), 0.005)
}
}