Módulo 1: Introducción a IA Multimodal

6. Formatos y APIs

Descripción

Hasta ahora construiste pipelines multimodales: enviaste imágenes, transcribiste audio, combinaste modalidades. Pero cada vez que algo fallaba —una imagen rechazada, un error 413, un rate limit— el problema no era tu lógica, era el formato. En esta cápsula vas a dominar la capa que está entre tu código y las APIs: Base64, URLs, tipos MIME, límites de tamaño, tokens de imagen, costos reales y rate limits. Es la cápsula más "infraestructura" del módulo, pero sin ella, todo lo demás se rompe en producción.

Por qué importa: Los errores de formato son la causa número uno de fallos en integraciones multimodales. Un archivo de 6 MB enviado a Anthropic (límite 5 MB) devuelve un error críptico. Una imagen en BMP enviada a OpenAI se rechaza sin explicación clara. Un batch de 50 imágenes que ignora rate limits recibe un 429 y pierde todo el progreso. Conocer los requisitos exactos de cada proveedor te ahorra horas de debugging y cientos de dólares en requests fallidos.

Conexión con el módulo: Esta cápsula es el complemento técnico de las cápsulas 02 (visión), 03 (audio) y 05 (landscape de modelos). Mientras esas cápsulas te enseñaron qué pueden hacer los modelos, esta te enseña cómo preparar los datos para que los acepten. El Clasificador multimodal (cápsula 08) necesita validar formatos antes de enviar cualquier archivo a una API.


Base64: Qué Es y Cómo Funciona

El concepto

Base64 es un esquema de codificación que convierte datos binarios (como una imagen o un archivo de audio) en una cadena de texto ASCII. Las APIs de IA lo usan porque los payloads JSON no pueden contener datos binarios directamente — necesitan texto.

La conversión infla el tamaño del archivo un ~33%. Una imagen de 3 MB se convierte en ~4 MB de texto Base64. Esto importa cuando trabajas con límites de tamaño.

Archivo binario (3 MB) → Base64 encoding → Cadena de texto (~4 MB)

Codificación básica

import base64
from pathlib import Path


def encode_image_to_base64(image_path: str) -> str:
    """Codifica una imagen local a Base64."""
    path = Path(image_path)
    if not path.exists():
        raise FileNotFoundError(f"No se encontró: {image_path}")

    with open(path, "rb") as f:
        raw_bytes = f.read()

    encoded = base64.b64encode(raw_bytes).decode("utf-8")
    return encoded


image_b64 = encode_image_to_base64("foto_producto.jpg")
print(f"Longitud de la cadena Base64: {len(image_b64):,} caracteres")
print(f"Primeros 80 caracteres: {image_b64[:80]}...")

Data URIs: el formato que espera OpenAI

OpenAI no acepta Base64 crudo. Necesita un data URI que incluya el tipo MIME:

def image_to_data_uri(image_path: str) -> str:
    """Convierte imagen local a data URI listo para la API de OpenAI."""
    path = Path(image_path)
    extension = path.suffix.lower()

    mime_types = {
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".png": "image/png",
        ".gif": "image/gif",
        ".webp": "image/webp",
    }

    mime = mime_types.get(extension)
    if not mime:
        raise ValueError(
            f"Formato no soportado: {extension}. "
            f"Usa: {', '.join(mime_types.keys())}"
        )

    encoded = encode_image_to_base64(image_path)
    return f"data:{mime};base64,{encoded}"


data_uri = image_to_data_uri("diagrama.png")
# Resultado: "data:image/png;base64,iVBORw0KGgo..."

Base64 en Anthropic: formato diferente

Anthropic no usa data URIs. Espera el Base64 y el tipo MIME como campos separados:

import anthropic

client_anthropic = anthropic.Anthropic()

def send_image_to_claude(image_path: str, prompt: str) -> str:
    """Envía imagen a Claude con formato Base64 de Anthropic."""
    path = Path(image_path)
    extension = path.suffix.lower()

    mime_map = {
        ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
        ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp",
    }

    encoded = encode_image_to_base64(image_path)

    response = client_anthropic.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {
                        "type": "base64",
                        "media_type": mime_map[extension],
                        "data": encoded,
                    },
                },
                {"type": "text", "text": prompt},
            ],
        }],
    )
    return response.content[0].text

URLs como Input de Imagen

Cuándo usar URLs

Si tu imagen ya está alojada en un servidor público (CDN, S3, GitHub), puedes enviar la URL directamente. El proveedor descarga la imagen por ti:

from openai import OpenAI

client = OpenAI()

def analyze_image_from_url(url: str, prompt: str) -> str:
    """Analiza una imagen usando su URL pública."""
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text", "text": prompt},
                {"type": "image_url", "image_url": {"url": url}},
            ],
        }],
        max_tokens=500,
    )
    return response.choices[0].message.content


result = analyze_image_from_url(
    "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg",
    "¿Qué animal aparece en la imagen y qué está haciendo?",
)
print(result)

Requisitos de las URLs

  • La URL debe ser accesible públicamente (sin autenticación)
  • OpenAI y Google aceptan URLs HTTP/HTTPS directas
  • Anthropic no acepta URLs directas — solo Base64
  • Google acepta URLs y también gs:// (Google Cloud Storage)
  • La URL debe apuntar a un archivo de imagen válido, no a una página HTML que contenga una imagen

Comparación: Base64 vs URL

CriterioBase64URL
PrivacidadLos datos van dentro del request, no necesitas servidor públicoLa imagen debe estar en un servidor accesible
Tamaño del requestGrande (~33% más que el archivo original)Pequeño (solo la cadena de la URL)
LatenciaUna sola requestLa API debe descargar la imagen primero
Archivos localesFunciona directamenteNecesitas subir la imagen a un servidor primero
CachingNo — cada request envía los datos completosEl proveedor puede cachear la imagen por URL
AnthropicÚnico método soportadoNo soportado
OpenAISoportadoSoportado
Google GeminiSoportadoSoportado (HTTP y gs://)
Confiabilidad100% — los datos van en el requestDepende de que la URL siga accesible

Regla práctica:

  • Archivos locales o privados → Base64
  • Imágenes ya hospedadas en CDN/S3 → URL
  • Anthropic → siempre Base64
  • Batch grande de imágenes públicas → URL (requests más ligeros)

Función Universal: Base64 o URL

Este helper detecta si el input es URL o ruta local y prepara el formato correcto para OpenAI:

import base64, re
from pathlib import Path

SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
MIME_TYPES = {
    ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
    ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp",
}

def prepare_image_content(source: str, detail: str = "auto") -> dict:
    is_url = bool(re.match(r"https?://", source))

    if is_url:
        image_url = source
    else:
        path = Path(source)
        if not path.exists():
            raise FileNotFoundError(f"No se encontró: {source}")
        if path.suffix.lower() not in SUPPORTED_FORMATS:
            raise ValueError(f"Formato no soportado: {path.suffix}")

        with open(path, "rb") as f:
            encoded = base64.b64encode(f.read()).decode("utf-8")
        mime = MIME_TYPES[path.suffix.lower()]
        image_url = f"data:{mime};base64,{encoded}"

    return {"type": "image_url", "image_url": {"url": image_url, "detail": detail}}

# Funciona igual con URL o ruta local
content_url = prepare_image_content("https://example.com/foto.jpg")
content_local = prepare_image_content("./fotos/producto.png", detail="high")

Límites de Tamaño por Proveedor

Imágenes

ProveedorLímite por imagenFormatos soportadosNotas
OpenAI20 MBPNG, JPEG, GIF, WebPGIF: solo primer frame
Anthropic5 MB (Base64)PNG, JPEG, GIF, WebPSolo Base64, no URLs
Google Gemini20 MBPNG, JPEG, GIF, WebP, BMPTambién acepta gs:// URIs

Audio (Whisper)

FormatoSoportado
mp3, mp4, mpeg, mpga, m4a, wav, webm
Límite de tamaño25 MB
Duración máximaSin límite explícito (pero el tamaño limita)

Formatos de respuesta de Whisper

FormatoDescripciónCuándo usar
jsonTexto plano en JSONCaso más simple
verbose_jsonIncluye timestamps por segmentoSubtítulos, sincronización
textSolo el texto sin formatoCuando necesitas string directo
srtSubRip SubtitleArchivos de subtítulos estándar
vttWebVTTSubtítulos para web/HTML5
# Transcripción con timestamps (verbose_json)
with open("reunion.mp3", "rb") as f:
    transcript = client.audio.transcriptions.create(
        model="whisper-1",
        file=f,
        language="es",
        response_format="verbose_json",
    )

for segment in transcript.segments:
    start = segment["start"]
    end = segment["end"]
    text = segment["text"]
    print(f"[{start:.1f}s - {end:.1f}s] {text}")

Redimensionar si excede el límite

from PIL import Image
import io


def resize_for_provider(
    image_path: str,
    provider: str = "openai",
) -> bytes:
    """Redimensiona imagen si excede el límite del proveedor."""
    limits_mb = {"openai": 20, "anthropic": 5, "google": 20}
    max_mb = limits_mb.get(provider, 20)

    with open(image_path, "rb") as f:
        raw = f.read()

    current_mb = len(raw) / (1024 * 1024)
    if current_mb <= max_mb:
        return raw

    with Image.open(image_path) as img:
        img = img.convert("RGB")
        buffer = io.BytesIO()
        quality = 95

        while True:
            buffer.seek(0)
            buffer.truncate()
            img.save(buffer, format="JPEG", quality=quality, optimize=True)

            if len(buffer.getvalue()) <= max_mb * 1024 * 1024:
                break

            quality -= 10
            if quality < 30:
                img = img.resize((img.width // 2, img.height // 2))
                quality = 85

        return buffer.getvalue()

Tokens de Imagen: Cómo OpenAI Cobra por Imágenes

Las imágenes en OpenAI no tienen un "precio por imagen" fijo. Se convierten a tokens según la resolución, y esos tokens se cobran igual que los tokens de texto.

Niveles de detalle

Nivel (detail)Tokens fijosCuándo usarlo
low85 tokensClasificación, detección general
high85 + 170 × N tilesOCR, lectura de texto fino, análisis detallado
autoOpenAI eligeCuando no estás seguro

Cálculo de tiles en modo high

OpenAI escala la imagen para que quepa en 2048×2048, luego la divide en tiles de 512×512:

import math


def calculate_image_tokens(width: int, height: int, detail: str = "high") -> int:
    """Calcula tokens de imagen según la documentación de OpenAI.

    Referencia: https://platform.openai.com/docs/guides/vision
    """
    if detail == "low":
        return 85

    max_dim = 2048
    if max(width, height) > max_dim:
        scale = max_dim / max(width, height)
        width = int(width * scale)
        height = int(height * scale)

    min_side = 768
    if min(width, height) > min_side:
        scale = min_side / min(width, height)
        width = int(width * scale)
        height = int(height * scale)

    tiles_x = math.ceil(width / 512)
    tiles_y = math.ceil(height / 512)
    total_tiles = tiles_x * tiles_y

    return 85 + (170 * total_tiles)


# Ejemplos
print(calculate_image_tokens(1024, 1024, "high"))   # 85 + 170*4 = 765
print(calculate_image_tokens(1024, 1024, "low"))     # 85
print(calculate_image_tokens(4000, 3000, "high"))    # 85 + 170*6 = 1105
print(calculate_image_tokens(512, 512, "high"))      # 85 + 170*2 = 425

Cómo afecta tu factura

def estimate_vision_cost(
    width: int,
    height: int,
    detail: str = "high",
    model: str = "gpt-4o",
    text_tokens: int = 200,
    output_tokens: int = 300,
) -> dict:
    """Estima el costo de una llamada con imagen."""
    pricing = {
        "gpt-4o": {"input": 2.50, "output": 10.00},
        "gpt-4o-mini": {"input": 0.15, "output": 0.60},
    }

    if model not in pricing:
        raise ValueError(f"Modelo no soportado: {model}")

    image_tokens = calculate_image_tokens(width, height, detail)
    total_input = image_tokens + text_tokens
    prices = pricing[model]

    input_cost = (total_input / 1_000_000) * prices["input"]
    output_cost = (output_tokens / 1_000_000) * prices["output"]
    total_cost = input_cost + output_cost

    return {
        "image_tokens": image_tokens,
        "text_tokens": text_tokens,
        "total_input_tokens": total_input,
        "output_tokens": output_tokens,
        "input_cost_usd": round(input_cost, 6),
        "output_cost_usd": round(output_cost, 6),
        "total_cost_usd": round(total_cost, 6),
    }


cost = estimate_vision_cost(1024, 1024, detail="high", model="gpt-4o")
print(f"Imagen 1024x1024 con gpt-4o: ${cost['total_cost_usd']:.4f}")
# ~$0.0049

cost_mini = estimate_vision_cost(1024, 1024, detail="high", model="gpt-4o-mini")
print(f"Imagen 1024x1024 con gpt-4o-mini: ${cost_mini['total_cost_usd']:.4f}")
# ~$0.0003

Costos por Modalidad: Números Reales

Tabla de precios (OpenAI, marzo 2025)

ModalidadModeloPrecioUnidad
Vision (input)gpt-4o$2.50por 1M tokens input
Vision (input)gpt-4o-mini$0.15por 1M tokens input
Vision (output)gpt-4o$10.00por 1M tokens output
Vision (output)gpt-4o-mini$0.60por 1M tokens output
Whisperwhisper-1$0.006por minuto de audio
TTStts-1$15.00por 1M caracteres
TTStts-1-hd$30.00por 1M caracteres
Imagen gen.dall-e-3 1024×1024$0.040por imagen (standard)
Imagen gen.dall-e-3 1024×1024$0.080por imagen (hd)
Imagen gen.dall-e-3 1792×1024$0.080por imagen (standard)
Imagen gen.dall-e-3 1792×1024$0.120por imagen (hd)

Escenarios reales de costo

EscenarioModeloCálculoCosto total
1 factura (1024×1024)gpt-4o765 img tokens + 100 text + 200 output~$0.0042
1 factura (1024×1024)gpt-4o-minimismos tokens, precios menores~$0.0002
30 min audio + resumirgpt-4o-mini + whisperWhisper $0.18 + LLM $0.0008~$0.1809
100 fotos de productosgpt-4o-mini76,500 img + 5,000 text tokens~$0.0181
1 imagen DALL-E 3dall-e-3 (standard)1024×1024$0.040
1000 caracteres TTStts-1$15/1M chars$0.015

La diferencia entre gpt-4o y gpt-4o-mini es ~17x para vision. Siempre evalúa si la calidad de gpt-4o-mini es suficiente para tu caso antes de escalar.


Rate Limits: Límites por Proveedor

Límites típicos (varía por tier)

ProveedorModeloRPM (requests/min)TPM (tokens/min)RPD (requests/día)
OpenAI (Tier 1)gpt-4o50030,00010,000
OpenAI (Tier 1)gpt-4o-mini500200,00010,000
OpenAI (Tier 1)whisper-15010,000
Anthropic (Build)claude-sonnet5040,0001,000
Google (Free)gemini-1.5-flash151,000,0001,500

Los límites suben conforme gastas más en la plataforma. Consulta la documentación oficial para tu tier actual.

Headers de respuesta

Cuando recibes un rate limit, la respuesta incluye headers que te dicen cuánto esperar:

x-ratelimit-limit-requests: 500
x-ratelimit-remaining-requests: 0
x-ratelimit-reset-requests: 12s
retry-after: 12

Exponential backoff

import time
import random
from openai import OpenAI, RateLimitError, APIError

client = OpenAI()


def call_with_backoff(
    func,
    max_retries: int = 5,
    base_delay: float = 1.0,
    max_delay: float = 60.0,
):
    """Ejecuta una función con exponential backoff ante rate limits."""
    for attempt in range(max_retries):
        try:
            return func()
        except RateLimitError as e:
            if attempt == max_retries - 1:
                raise

            delay = min(base_delay * (2 ** attempt), max_delay)
            jitter = random.uniform(0, delay * 0.1)
            wait = delay + jitter

            print(f"Rate limit (intento {attempt + 1}/{max_retries}). "
                  f"Esperando {wait:.1f}s...")
            time.sleep(wait)

        except APIError as e:
            if e.status_code and e.status_code >= 500:
                if attempt == max_retries - 1:
                    raise
                time.sleep(base_delay * (2 ** attempt))
            else:
                raise


result = call_with_backoff(
    lambda: client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": "Di hola"}],
        max_tokens=10,
    )
)
print(result.choices[0].message.content)

Troubleshooting

Problema 1: Invalid image format o Could not process image

Causa: El archivo tiene extensión .jpg pero internamente es otro formato (ej: un PNG renombrado), o es un formato no soportado como BMP o TIFF.

Solución:

from PIL import Image


def validate_and_convert(image_path: str) -> str:
    """Valida imagen y la convierte a JPEG si es necesario."""
    try:
        with Image.open(image_path) as img:
            img.verify()
    except Exception:
        raise ValueError(f"No es una imagen válida: {image_path}")

    with Image.open(image_path) as img:
        if img.format not in ("JPEG", "PNG", "GIF", "WEBP"):
            converted_path = str(Path(image_path).with_suffix(".jpg"))
            img.convert("RGB").save(converted_path, "JPEG", quality=90)
            return converted_path

    return image_path

Problema 2: Request entity too large (413)

Causa: La imagen codificada en Base64 excede el límite del proveedor. Recuerda que Base64 infla ~33%.

Solución: Usa la función resize_for_provider de la sección de límites. Para Anthropic (5 MB), una imagen de 4 MB en disco ya excede el límite después de la codificación Base64 (~5.3 MB).

Problema 3: Rate limit exceeded (429)

Causa: Enviaste demasiadas requests en poco tiempo.

Solución: Implementa exponential backoff (sección anterior) y reduce requests_per_minute en batch processing. Revisa tu tier en el dashboard del proveedor — los tiers más altos tienen límites mayores.

Problema 4: Invalid API key o Authentication error

Causa: La API key no está configurada, expiró, o se copió mal (espacios extra, caracteres faltantes).

Solución: Verifica que tu .env tenga la key sin espacios extra, que empiece con sk- y tenga más de 20 caracteres. Usa load_dotenv() antes de crear el client. Si el problema persiste, regenera la key desde el dashboard del proveedor.

Problema 5: Imagen por URL devuelve error pero la URL funciona en el navegador

Causa: La URL redirige (301/302), requiere cookies, o tiene restricción de user-agent. Las APIs no siguen todas las redirecciones.

Solución: Descarga la imagen primero con httpx.get(url, follow_redirects=True) y envíala como Base64. Esto resuelve el 95% de los problemas con URLs.


Ejercicios

Ejercicio 1: Codificador Base64 con validación (Fácil)

Crea una función safe_encode(path) que verifique que el archivo existe, valide formato (PNG, JPEG, GIF, WebP), y retorne un dict con base64, mime, y size_mb.

Ver solución
import base64
from pathlib import Path
from PIL import Image

SUPPORTED = {
    ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
    ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp",
}

def safe_encode(path: str) -> dict:
    p = Path(path)
    if not p.exists():
        return {"success": False, "error": f"No encontrado: {path}"}

    ext = p.suffix.lower()
    if ext not in SUPPORTED:
        return {"success": False, "error": f"Formato no soportado: {ext}"}

    try:
        with Image.open(path) as img:
            img.verify()
    except Exception as e:
        return {"success": False, "error": f"Imagen inválida: {e}"}

    with open(path, "rb") as f:
        raw = f.read()

    return {
        "success": True,
        "base64": base64.b64encode(raw).decode("utf-8"),
        "mime": SUPPORTED[ext],
        "size_mb": round(len(raw) / (1024 * 1024), 2),
    }

Explicación: Validación en tres capas (existencia → extensión → contenido real) atrapa errores antes de enviar a la API.

Ejercicio 2: Calculadora de costos por batch (Fácil)

Crea una función que calcule el costo de procesar N imágenes con un modelo dado.

Ver solución
import math

def calculate_batch_cost(n_images: int, model: str = "gpt-4o-mini") -> dict:
    pricing = {
        "gpt-4o": {"input": 2.50, "output": 10.00},
        "gpt-4o-mini": {"input": 0.15, "output": 0.60},
    }
    img_tokens = 765  # 1024x1024, detail=high
    prompt_tokens, output_tokens = 50, 100
    p = pricing[model]

    total_input = (img_tokens + prompt_tokens) * n_images
    total_output = output_tokens * n_images
    cost = (total_input / 1e6) * p["input"] + (total_output / 1e6) * p["output"]

    return {"n": n_images, "model": model, "total_cost": round(cost, 4)}

for n in [10, 100, 1000]:
    for m in ["gpt-4o", "gpt-4o-mini"]:
        r = calculate_batch_cost(n, m)
        print(f"{n:>5} imgs × {m:<12} = ${r['total_cost']:.4f}")

Explicación: gpt-4o-mini es ~17x más barato que gpt-4o para vision. 1000 imágenes cuestan ~$0.18 vs ~$3.19.

Ejercicio 3: Auto-selector Base64 vs URL (Medio)

Crea una función que detecte si un source es URL o ruta local, y prepare el formato correcto según el proveedor (OpenAI usa URL directo, Anthropic siempre necesita Base64).

Ver solución
import re, httpx

LIMITS_MB = {"openai": 20, "anthropic": 5, "google": 20}

def smart_image_input(source: str, provider: str = "openai") -> dict:
    is_url = bool(re.match(r"https?://", source))

    if is_url and provider in ("openai", "google"):
        return {"method": "url", "content": {"type": "image_url", "image_url": {"url": source}}}

    if is_url:
        resp = httpx.get(source, follow_redirects=True, timeout=30)
        image_bytes = resp.content
        mime = resp.headers.get("content-type", "image/jpeg")
    else:
        with open(source, "rb") as f:
            image_bytes = f.read()
        mime = MIME_TYPES.get(Path(source).suffix.lower(), "image/jpeg")

    encoded = base64.b64encode(image_bytes).decode("utf-8")

    if provider == "anthropic":
        content = {"type": "image", "source": {"type": "base64", "media_type": mime, "data": encoded}}
    else:
        content = {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}}

    return {"method": "base64", "content": content}

Explicación: Encapsula la decisión URL vs Base64 y las diferencias de formato entre proveedores.

Ejercicio 4: Rate limit handler con backoff (Medio)

Crea una función call_with_backoff que reintente hasta 5 veces con exponential backoff y jitter ante rate limits.

Ver solución
import time, random
from openai import RateLimitError

def call_with_backoff(func, max_retries=5, base_delay=1.0):
    for attempt in range(max_retries):
        try:
            return func()
        except RateLimitError:
            if attempt == max_retries - 1:
                raise
            delay = min(base_delay * (2 ** attempt), 60)
            jitter = random.uniform(0, delay * 0.25)
            print(f"Rate limit, esperando {delay + jitter:.1f}s...")
            time.sleep(delay + jitter)

Explicación: Exponential backoff (1s, 2s, 4s, 8s...) + jitter evita que múltiples clientes reintenten al mismo tiempo.


Resumen

En esta cápsula aprendiste:

  • Base64 codifica datos binarios como texto para enviar en JSON. Infla ~33% el tamaño. OpenAI usa data URIs (data:image/png;base64,...), Anthropic usa campos separados (media_type + data)
  • URLs son más ligeras pero solo funcionan con imágenes públicas. Anthropic no las acepta — siempre necesitas Base64 para Claude
  • Los límites de tamaño varían: OpenAI 20 MB, Anthropic 5 MB, Google 20 MB. Redimensiona antes de enviar
  • Los tokens de imagen en OpenAI dependen de la resolución: 85 tokens en low, 85 + 170×tiles en high. Una imagen 1024×1024 = 765 tokens
  • Los costos reales son bajos para gpt-4o-mini ($0.0002/imagen) pero se acumulan en batches grandes con gpt-4o ($0.003/imagen). Whisper: $0.006/minuto
  • Rate limits se manejan con exponential backoff + jitter. Los headers de respuesta (retry-after) te dicen cuánto esperar
  • API keys van en .env, nunca en el código. Valida al iniciar que existen y tienen formato correcto
  • MIME types deben coincidir con el formato real del archivo, no solo la extensión

Próxima cápsula: Troubleshooting multimodal — errores comunes en producción, diagnóstico sistemático, y estrategias de fallback multi-proveedor.


Recursos Adicionales

  1. OpenAI Vision Guide — Documentación oficial de GPT-4 Vision con formatos y límites
  2. OpenAI Pricing — Precios actualizados de todos los modelos
  3. Anthropic Vision Docs — Formato Base64 para Claude
  4. Google Gemini Vision — Formatos soportados en Gemini
  5. OpenAI Rate Limits — Límites por tier y estrategias de manejo
  6. Pillow (PIL) Documentation — Manipulación y validación de imágenes en Python
  7. Base64 Encoding (MDN) — Referencia técnica del esquema de codificación
  8. httpx Documentation — Cliente HTTP moderno para Python (descarga de imágenes por URL)