from __future__ import annotations import base64 import math import re from io import BytesIO from typing import Any from PIL import Image DEFAULT_IMAGE_SIZE = (1024, 1024) IMAGE_INPUT_TOKEN_MODEL = "gpt-5.4-mini" PATCH_SIZE = 32 TILE_SIZE = 512 TILE_HIGH_SHORT_SIDE = 768 PATCH_1536_MODELS = ( "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5-mini", "gpt-5-nano", "gpt-5.2", "gpt-5.3-codex", "gpt-5-codex-mini", "gpt-5.1-codex-mini", "gpt-5.2-codex", "gpt-5.2-chat-latest", "o4-mini", "gpt-4.1-mini", "gpt-4.1-nano", ) PATCH_MULTIPLIERS = { "gpt-5.4-mini": 1.62, "gpt-5.4-nano": 2.46, "gpt-5-mini": 1.62, "gpt-5-nano": 2.46, "gpt-4.1-mini": 1.62, "gpt-4.1-nano": 2.46, "o4-mini": 1.72, } def _model_name(model: str) -> str: return str(model or "").strip().lower() def image_size_from_bytes(data: bytes) -> tuple[int, int] | None: if not data: return None try: with Image.open(BytesIO(data)) as image: width, height = image.size except Exception: return None if width <= 0 or height <= 0: return None return int(width), int(height) def _decode_data_url(value: str) -> bytes: text = str(value or "").strip() payload = text.split(",", 1)[1] if text.startswith("data:") and "," in text else text return base64.b64decode(payload) def image_size_from_data_url(value: str) -> tuple[int, int] | None: try: return image_size_from_bytes(_decode_data_url(value)) except Exception: return None def parse_image_size(size: object, default: tuple[int, int] = DEFAULT_IMAGE_SIZE) -> tuple[int, int]: if isinstance(size, (tuple, list)) and len(size) >= 2: try: width = int(size[0]) height = int(size[1]) if width > 0 and height > 0: return width, height except (TypeError, ValueError): pass match = re.search(r"(\d{2,5})\D+(\d{2,5})", str(size or "")) if not match: return default width, height = int(match.group(1)), int(match.group(2)) return (width, height) if width > 0 and height > 0 else default def _patch_count(width: float, height: float) -> int: return math.ceil(width / PATCH_SIZE) * math.ceil(height / PATCH_SIZE) def _patch_multiplier(model: str) -> float: name = _model_name(model) for prefix, multiplier in PATCH_MULTIPLIERS.items(): if name.startswith(prefix): return multiplier return 1.0 def _patch_limits(model: str, detail: str) -> tuple[int, int] | None: name = _model_name(model) if any(name.startswith(prefix) for prefix in PATCH_1536_MODELS): return 1536, 2048 if name.startswith("gpt-5.5"): return (10000, 6000) if detail in {"auto", "original"} else (2500, 2048) if name.startswith("gpt-5.4"): return (10000, 6000) if detail == "original" else (2500, 2048) return None def _patch_tokens(width: int, height: int, model: str, detail: str) -> int: multiplier = _patch_multiplier(model) if detail == "low": return math.ceil(256 * multiplier) limits = _patch_limits(model, detail) if limits is None: return 0 patch_budget, max_dimension = limits scale = min(1.0, max_dimension / max(width, height)) resized_width = width * scale resized_height = height * scale if _patch_count(resized_width, resized_height) > patch_budget: shrink_factor = math.sqrt((PATCH_SIZE * PATCH_SIZE * patch_budget) / (resized_width * resized_height)) width_units = resized_width * shrink_factor / PATCH_SIZE height_units = resized_height * shrink_factor / PATCH_SIZE adjusted_shrink_factor = shrink_factor * min( math.floor(width_units) / width_units if width_units else 1, math.floor(height_units) / height_units if height_units else 1, ) resized_width *= adjusted_shrink_factor resized_height *= adjusted_shrink_factor tokens = min(_patch_count(max(1, resized_width), max(1, resized_height)), patch_budget) return math.ceil(tokens * multiplier) def _tile_rates(model: str) -> tuple[int, int]: name = _model_name(model) if name in {"gpt-5", "gpt-5-chat-latest"}: return 70, 140 if name.startswith("gpt-4o-mini"): return 2833, 5667 if name.startswith(("o1", "o1-pro", "o3")): return 75, 150 if name.startswith("computer-use-preview"): return 65, 129 return 85, 170 def _tile_tokens(width: int, height: int, model: str, detail: str) -> int: base_tokens, tile_tokens = _tile_rates(model) if detail == "low": return base_tokens scale = min(1.0, 2048 / max(width, height)) resized_width = width * scale resized_height = height * scale short_side = min(resized_width, resized_height) if short_side > 0: scale = TILE_HIGH_SHORT_SIDE / short_side resized_width *= scale resized_height *= scale tiles = math.ceil(resized_width / TILE_SIZE) * math.ceil(resized_height / TILE_SIZE) return base_tokens + tiles * tile_tokens def count_image_input_tokens( width: int, height: int, model: str, detail: str = "auto", input_fidelity: str = "low", ) -> int: if width <= 0 or height <= 0: return 0 detail = str(detail or "auto").strip().lower() or "auto" return _patch_tokens(width, height, IMAGE_INPUT_TOKEN_MODEL, detail) def _part_size(part: dict[str, Any]) -> tuple[int, int] | None: try: width = int(part.get("width") or 0) height = int(part.get("height") or 0) except (TypeError, ValueError): width = height = 0 if width > 0 and height > 0: return width, height data = part.get("data") if isinstance(data, (bytes, bytearray)): return image_size_from_bytes(bytes(data)) image_url = part.get("image_url") if isinstance(image_url, dict): image_url = image_url.get("url") or image_url.get("image_url") if isinstance(image_url, str) and image_url.startswith("data:"): return image_size_from_data_url(image_url) source = part.get("source") if isinstance(source, dict) and str(source.get("type") or "") == "base64": try: return image_size_from_bytes(base64.b64decode(str(source.get("data") or ""))) except Exception: return None return None def count_image_content_tokens(content: object, model: str, default_detail: str = "auto") -> int: if not isinstance(content, list): return 0 total = 0 for part in content: if not isinstance(part, dict): continue part_type = str(part.get("type") or "").strip() if part_type not in {"image", "image_url", "input_image"} and not part.get("source"): continue size = _part_size(part) if not size: continue total += count_image_input_tokens( size[0], size[1], model, str(part.get("detail") or default_detail or "auto"), str(part.get("input_fidelity") or part.get("inputFidelity") or "low"), ) return total def count_image_inputs_tokens(images: object, model: str, default_detail: str = "auto") -> int: if not images: return 0 total = 0 entries = images if isinstance(images, list) else [images] for image in entries: data = image[0] if isinstance(image, tuple) and image else image if not isinstance(data, (bytes, bytearray)): continue size = image_size_from_bytes(bytes(data)) if size: total += count_image_input_tokens(size[0], size[1], model, default_detail) return total def count_generated_image_tokens(width: int, height: int, quality: str = "auto") -> int: patches = _patch_count(width, height) quality = str(quality or "auto").strip().lower() if quality == "low": return math.ceil(patches * 17 / 64) if quality in {"high", "hd"}: return math.ceil(patches * 65 / 16) return math.ceil(patches * 33 / 32) def count_image_output_tokens(size: object = None, quality: str = "auto", count: int = 1) -> int: width, height = parse_image_size(size) return max(0, int(count or 0)) * count_generated_image_tokens(width, height, quality) def count_image_output_items_tokens( items: object, size: object = None, quality: str = "auto", ) -> int: if not isinstance(items, list) or not items: return 0 fallback_size = parse_image_size(size) total = 0 for item in items: image_size = None if isinstance(item, dict): b64_json = str(item.get("b64_json") or "").strip() if b64_json: try: image_size = image_size_from_bytes(base64.b64decode(b64_json)) except Exception: image_size = None width, height = image_size or fallback_size total += count_generated_image_tokens(width, height, quality) return total def token_usage( input_text_tokens: int = 0, input_image_tokens: int = 0, output_text_tokens: int = 0, output_image_tokens: int = 0, ) -> dict[str, Any]: input_tokens = max(0, int(input_text_tokens or 0)) + max(0, int(input_image_tokens or 0)) output_tokens = max(0, int(output_text_tokens or 0)) + max(0, int(output_image_tokens or 0)) return { "input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": input_tokens + output_tokens, "input_tokens_details": { "text_tokens": max(0, int(input_text_tokens or 0)), "image_tokens": max(0, int(input_image_tokens or 0)), "cached_tokens": 0, }, "output_tokens_details": { "text_tokens": max(0, int(output_text_tokens or 0)), "image_tokens": max(0, int(output_image_tokens or 0)), "reasoning_tokens": 0, }, } def image_usage( input_text_tokens: int = 0, input_image_tokens: int = 0, output_tokens: int = 0, ) -> dict[str, Any]: return token_usage( input_text_tokens=input_text_tokens, input_image_tokens=input_image_tokens, output_image_tokens=output_tokens, ) def chat_usage_from_image_usage(usage: dict[str, Any]) -> dict[str, Any]: input_tokens = int(usage.get("input_tokens") or 0) output_tokens = int(usage.get("output_tokens") or 0) input_details = usage.get("input_tokens_details") if isinstance(usage.get("input_tokens_details"), dict) else {} output_details = usage.get("output_tokens_details") if isinstance(usage.get("output_tokens_details"), dict) else {} return { "prompt_tokens": input_tokens, "completion_tokens": output_tokens, "total_tokens": input_tokens + output_tokens, "prompt_tokens_details": { "text_tokens": int(input_details.get("text_tokens") or 0), "image_tokens": int(input_details.get("image_tokens") or 0), "cached_tokens": int(input_details.get("cached_tokens") or 0), }, "completion_tokens_details": { "text_tokens": int(output_details.get("text_tokens") or 0), "image_tokens": int(output_details.get("image_tokens") or 0), "reasoning_tokens": int(output_details.get("reasoning_tokens") or 0), }, }