Files
new-api-custom/tokenkit/image.go
T

291 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}