feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user