Módulo 2: Vision + LLMs

3. Claude 3 Vision (Anthropic)

Descripción

Claude 3 es la familia de modelos multimodales de Anthropic que compite directamente con GPT-4o de OpenAI. Soporta imágenes como input junto con texto, lo que permite realizar las mismas tareas que vimos en la cápsula anterior: descripción, OCR, clasificación y extracción estructurada.

¿Por qué importa Anthropic como alternativa? Tres razones: una ventana de contexto de 200K tokens (vs 128K de GPT-4o), rendimiento superior en OCR y análisis de documentos según benchmarks independientes, y una API con diferencias estructurales que todo AI Engineer debe dominar para no depender de un solo proveedor.

Esta cápsula se enfoca en las diferencias con OpenAI. Si algo funciona igual, no lo repetiremos — consulta la cápsula 02 para los fundamentos.


Modelos con Vision

Anthropic ofrece tres modelos con capacidades de visión, cada uno optimizado para un balance diferente entre calidad, velocidad y costo:

ModeloVisiónContextoCosto Input (aprox)Costo Output (aprox)Mejor para
claude-3-5-sonnet-latestExcelente200K tokens$3.00 / 1M tokens$15.00 / 1M tokensDefault recomendado. Balance óptimo calidad/costo
claude-3-opus-latestSuperior200K tokens$15.00 / 1M tokens$75.00 / 1M tokensAnálisis complejos, razonamiento profundo
claude-3-haiku-20240307Buena200K tokens$0.25 / 1M tokens$1.25 / 1M tokensTareas simples, alto volumen, baja latencia

Recomendación: Usa claude-3-5-sonnet-latest como default. Tiene la mejor relación calidad-precio y su rendimiento en visión es comparable a Opus en la mayoría de tareas. Reserva Opus para análisis que requieran razonamiento multi-paso complejo. Usa Haiku cuando necesites velocidad y el análisis sea simple (clasificación binaria, detección de contenido).

Comparado con OpenAI:

AspectoOpenAIAnthropic
Modelo económicogpt-4o-mini ($0.15/1M in)claude-3-haiku ($0.25/1M in)
Modelo balancegpt-4o ($2.50/1M in)claude-3-5-sonnet ($3.00/1M in)
Contexto máximo128K tokens200K tokens

Estructura del Request — Diferencias con OpenAI

Esta es la diferencia más importante entre ambas APIs. Aunque el concepto es el mismo (enviar un array de content blocks), la estructura JSON es completamente distinta.

En la cápsula 02 vimos que OpenAI usa {"type": "image_url", "image_url": {"url": "data:mime;base64,..."}}. Anthropic tiene una estructura distinta:

Anthropic

import anthropic
import base64

client = anthropic.Anthropic()

with open("imagen.jpg", "rb") as f:
    image_data = base64.b64encode(f.read()).decode()

response = client.messages.create(
    model="claude-3-5-sonnet-latest",
    max_tokens=1024,
    messages=[{
        "role": "user",
        "content": [
            {
                "type": "image",
                "source": {
                    "type": "base64",
                    "media_type": "image/jpeg",
                    "data": image_data
                }
            },
            {"type": "text", "text": "Describe esta imagen."}
        ]
    }]
)

result = response.content[0].text

Tabla comparativa de estructuras

ElementoOpenAIAnthropic
Tipo de bloque imagen"type": "image_url""type": "image"
Datos de imagen"image_url": {"url": "data:mime;base64,DATA"}"source": {"type": "base64", "media_type": "...", "data": "..."}
Media typeEmbebido en el data URICampo media_type explícito y obligatorio
URLs directasSí, acepta URLs públicasNo soportado
Método de la APIclient.chat.completions.create()client.messages.create()
Acceso a respuestaresponse.choices[0].message.contentresponse.content[0].text
Parámetro tokensmax_tokens (opcional)max_tokens (obligatorio)

El punto clave: en OpenAI el Base64 va dentro de un data URI (data:image/jpeg;base64,XXXX), mientras que en Anthropic el Base64 va en un campo data separado y el media type en su propio campo. Esto hace que Anthropic sea más explícito pero también más estricto — un media type incorrecto generará un error.


Solo Base64 — No URLs

Esta es la limitación más impactante de Anthropic vs OpenAI. Mientras que OpenAI acepta tanto Base64 como URLs públicas, Anthropic solo acepta Base64. No puedes pasar una URL directamente.

Esto significa que si tu imagen está en un servidor remoto, debes descargarla primero y convertirla a Base64 antes de enviarla a Claude.

import anthropic
import base64
import httpx
from pathlib import Path

client = anthropic.Anthropic()

def download_and_encode(url: str) -> tuple[str, str]:
    """Descarga una imagen desde URL y retorna (base64_data, media_type)."""
    response = httpx.get(url, follow_redirects=True, timeout=30)
    response.raise_for_status()

    content_type = response.headers.get("content-type", "image/jpeg")
    media_type = content_type.split(";")[0].strip()

    allowed = {"image/jpeg", "image/png", "image/gif", "image/webp"}
    if media_type not in allowed:
        raise ValueError(f"Formato no soportado: {media_type}")

    data = response.content
    if len(data) > 5 * 1024 * 1024:
        raise ValueError(f"Imagen excede 5MB: {len(data) / 1024 / 1024:.1f}MB")

    return base64.b64encode(data).decode(), media_type

Límite de 5MB

Anthropic impone un límite de 5MB por imagen en Base64. En la práctica, como Base64 aumenta el tamaño ~33%, esto significa que tu imagen original no debe superar ~3.75MB. Si necesitas enviar imágenes más grandes, redimensiona con PIL antes de codificar.


Media Types

Anthropic soporta cuatro formatos de imagen:

FormatoMedia TypeNotas
JPEGimage/jpegEl más común. Buena compresión para fotos
PNGimage/pngIdeal para capturas de pantalla, texto, diagramas
GIFimage/gifSolo el primer frame si es animado
WebPimage/webpFormato moderno, buena compresión

A diferencia de OpenAI donde el media type va embebido en el data URI y puede inferirse, en Anthropic es un campo obligatorio separado. Enviar un media type incorrecto produce un error.

Función de auto-detección:

from pathlib import Path

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

def detect_media_type(path: str) -> str:
    ext = Path(path).suffix.lower()
    if ext not in MEDIA_TYPES:
        raise ValueError(f"Extensión no soportada: {ext}. Usa: {list(MEDIA_TYPES.keys())}")
    return MEDIA_TYPES[ext]

Ejemplo 1: Descripción de Imagen

Ejemplo completo que carga una imagen local y obtiene una descripción:

import anthropic
import base64
from pathlib import Path

client = anthropic.Anthropic()

def describe_image(image_path: str, language: str = "español") -> str:
    ext = Path(image_path).suffix.lower()
    media_types = {".jpg": "image/jpeg", ".jpeg": "image/jpeg",
                   ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp"}
    media_type = media_types.get(ext, "image/jpeg")

    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    response = client.messages.create(
        model="claude-3-5-sonnet-latest",
        max_tokens=512,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {"type": "base64", "media_type": media_type, "data": image_data}
                },
                {
                    "type": "text",
                    "text": f"Describe esta imagen en detalle en {language}. "
                            f"Incluye: elementos principales, colores, composición y contexto."
                }
            ]
        }]
    )
    return response.content[0].text

description = describe_image("foto_producto.jpg")
print(description)

Diferencia práctica con OpenAI: el prompt y la estructura son equivalentes en funcionalidad, pero nota cómo aquí debemos especificar media_type explícitamente y max_tokens es obligatorio (en OpenAI es opcional).


Ejemplo 2: OCR con Claude

Claude destaca particularmente en OCR — en benchmarks independientes supera a GPT-4o en extracción de texto de documentos escaneados, recibos y formularios. Esto lo convierte en la opción preferida para pipelines de procesamiento documental.

import anthropic
import base64

client = anthropic.Anthropic()

def ocr_claude(image_path: str, structured: bool = False) -> str:
    with open(image_path, "rb") as f:
        b64 = base64.b64encode(f.read()).decode()

    if structured:
        prompt = (
            "Extrae todo el texto visible en esta imagen. "
            "Organiza el resultado respetando la estructura visual: "
            "encabezados, párrafos, listas, tablas. "
            "Usa formato Markdown para representar la estructura."
        )
    else:
        prompt = (
            "Extrae todo el texto visible en esta imagen, "
            "línea por línea, exactamente como aparece."
        )

    response = client.messages.create(
        model="claude-3-5-sonnet-latest",
        max_tokens=4096,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {"type": "base64", "media_type": "image/png", "data": b64}
                },
                {"type": "text", "text": prompt}
            ]
        }]
    )
    return response.content[0].text

text_raw = ocr_claude("recibo.png")
text_structured = ocr_claude("recibo.png", structured=True)
print(text_structured)

Para OCR con Claude, usa max_tokens alto (4096+) porque documentos escaneados pueden generar mucho texto. Con OpenAI el default de max_tokens es 4096, pero en Anthropic debes especificarlo explícitamente.


Ejemplo 3: Análisis de Documentos

Claude sobresale en análisis de documentos complejos: contratos, facturas, reportes. Su ventana de 200K tokens permite procesar documentos extensos con contexto adicional.

import anthropic
import base64

client = anthropic.Anthropic()

def analyze_document(image_path: str, document_type: str, fields: list[str]) -> str:
    with open(image_path, "rb") as f:
        b64 = base64.b64encode(f.read()).decode()

    fields_list = "\n".join(f"- {field}" for field in fields)

    response = client.messages.create(
        model="claude-3-5-sonnet-latest",
        max_tokens=2048,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {"type": "base64", "media_type": "image/png", "data": b64}
                },
                {
                    "type": "text",
                    "text": f"Este es un documento de tipo: {document_type}.\n\n"
                            f"Extrae los siguientes campos:\n{fields_list}\n\n"
                            f"Para cada campo, indica el valor encontrado. "
                            f"Si un campo no es visible, indica 'No encontrado'."
                }
            ]
        }]
    )
    return response.content[0].text

result = analyze_document(
    "factura.png",
    document_type="factura comercial",
    fields=["Número de factura", "Fecha", "Proveedor", "Total", "IVA", "Método de pago"]
)
print(result)

Claude soporta múltiples imágenes en el mismo mensaje — simplemente agrega varios bloques {"type": "image", ...} al array content antes del bloque de texto.


Ejemplo 4: Extracción JSON

En OpenAI puedes usar response_format={"type": "json_object"} para garantizar output JSON. Anthropic no tiene este parámetro. En su lugar, debes usar prompt engineering para obtener JSON válido.

import anthropic
import base64
import json

client = anthropic.Anthropic()

def extract_json_from_image(image_path: str, schema_description: str) -> dict:
    with open(image_path, "rb") as f:
        b64 = base64.b64encode(f.read()).decode()

    response = client.messages.create(
        model="claude-3-5-sonnet-latest",
        max_tokens=2048,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {"type": "base64", "media_type": "image/jpeg", "data": b64}
                },
                {
                    "type": "text",
                    "text": f"Analiza esta imagen y extrae la información en JSON.\n\n"
                            f"Schema esperado:\n{schema_description}\n\n"
                            f"Responde ÚNICAMENTE con el JSON válido, sin texto adicional, "
                            f"sin bloques de código markdown, sin explicaciones."
                }
            ]
        }]
    )

    raw = response.content[0].text.strip()
    if raw.startswith("```"):
        raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()

    return json.loads(raw)

product_data = extract_json_from_image(
    "producto.jpg",
    '{"nombre": "string", "precio": number, "categoria": "string", "color": "string"}'
)
print(json.dumps(product_data, indent=2, ensure_ascii=False))

Comparación de estrategias para JSON:

AspectoOpenAIAnthropic
Forzar JSONresponse_format={"type": "json_object"}No disponible
EstrategiaParámetro nativoPrompt engineering
Fiabilidad~99% con response_format~95% con buen prompt
Post-procesadoDirecto json.loads()Puede requerir limpieza de markdown

El bloque de limpieza (if raw.startswith("```")) es necesario porque Claude a veces envuelve el JSON en bloques de código markdown, incluso cuando se le pide no hacerlo.


System Prompt en Anthropic

Otra diferencia estructural importante: en OpenAI el system prompt es un mensaje con "role": "system" dentro del array messages. En Anthropic, el system prompt es un parámetro top-level separado.

import anthropic
import base64

client = anthropic.Anthropic()

with open("imagen.jpg", "rb") as f:
    b64 = base64.b64encode(f.read()).decode()

response = client.messages.create(
    model="claude-3-5-sonnet-latest",
    max_tokens=1024,
    system="Eres un experto en análisis visual de productos. "
           "Siempre respondes en español con formato estructurado.",
    messages=[{
        "role": "user",
        "content": [
            {
                "type": "image",
                "source": {"type": "base64", "media_type": "image/jpeg", "data": b64}
            },
            {"type": "text", "text": "Analiza este producto."}
        ]
    }]
)

Si incluyes {"role": "system", ...} dentro de messages en Anthropic, obtendrás un error. Es un error frecuente al migrar código de OpenAI a Anthropic.


Tokens y Costos

Anthropic incluye información de uso en cada respuesta. Es esencial para monitorear costos en producción.

import anthropic
import base64

client = anthropic.Anthropic()

with open("imagen.jpg", "rb") as f:
    b64 = base64.b64encode(f.read()).decode()

response = client.messages.create(
    model="claude-3-5-sonnet-latest",
    max_tokens=1024,
    messages=[{
        "role": "user",
        "content": [
            {
                "type": "image",
                "source": {"type": "base64", "media_type": "image/jpeg", "data": b64}
            },
            {"type": "text", "text": "Describe esta imagen."}
        ]
    }]
)

input_tokens = response.usage.input_tokens
output_tokens = response.usage.output_tokens

COST_PER_M_INPUT = 3.00
COST_PER_M_OUTPUT = 15.00

cost_input = (input_tokens / 1_000_000) * COST_PER_M_INPUT
cost_output = (output_tokens / 1_000_000) * COST_PER_M_OUTPUT
total_cost = cost_input + cost_output

print(f"Input:  {input_tokens:,} tokens (${cost_input:.4f})")
print(f"Output: {output_tokens:,} tokens (${cost_output:.4f})")
print(f"Total:  ${total_cost:.4f}")

Las imágenes consumen tokens de input. Una imagen típica de 1024x1024 consume aproximadamente 1,600 tokens. Imágenes más grandes consumen más. Anthropic no documenta la fórmula exacta, pero puedes usar response.usage.input_tokens para medir el consumo real.

Comparación del objeto de uso:

CampoOpenAIAnthropic
Tokens de entradaresponse.usage.prompt_tokensresponse.usage.input_tokens
Tokens de salidaresponse.usage.completion_tokensresponse.usage.output_tokens
Totalresponse.usage.total_tokensCalcular manualmente

Troubleshooting

1. Error: Imagen excede 5MB

anthropic.BadRequestError: Image exceeds maximum size of 5MB

Causa: La imagen codificada en Base64 supera 5MB.

Solución: Redimensiona o comprime la imagen antes de codificar. Usa la función resize_for_claude mostrada en la sección "Solo Base64".

2. Error: URL no soportada

anthropic.BadRequestError: Invalid image source type

Causa: Intentas pasar una URL directa como hacías en OpenAI.

Solución: Descarga la imagen primero y envíala como Base64. Usa download_and_encode() de la sección anterior.

3. Error: Media type incorrecto

anthropic.BadRequestError: Invalid media type

Causa: El campo media_type no coincide con el formato real de la imagen, o usaste un formato no soportado.

Solución: Verifica que la extensión del archivo corresponda al media_type. Usa la función detect_media_type() para automatizar.

4. Error: System prompt como mensaje

anthropic.BadRequestError: Messages must not contain "system" role

Causa: Migraste código de OpenAI sin cambiar el system prompt de mensaje a parámetro top-level.

Solución: Mueve el contenido de {"role": "system", "content": "..."} al parámetro system= de messages.create().

5. Error: max_tokens faltante

anthropic.BadRequestError: max_tokens is required

Causa: En OpenAI max_tokens es opcional (tiene default). En Anthropic es obligatorio.

Solución: Siempre incluye max_tokens en la llamada. Valores comunes: 512 para descripciones cortas, 1024 para análisis, 4096 para OCR extenso.


Ejercicios

Ejercicio 1 (Fácil): Envío universal de imagen a Claude

Crea una función send_image_to_claude(image_path, prompt) que:

  • Detecte automáticamente el media_type según la extensión del archivo
  • Valide que el archivo no exceda 5MB
  • Envíe la imagen a Claude y retorne la respuesta
  • Lance ValueError si el formato no es soportado o el archivo es muy grande
Ver solución
import anthropic
import base64
from pathlib import Path

client = anthropic.Anthropic()

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

MAX_SIZE_BYTES = 5 * 1024 * 1024

def send_image_to_claude(image_path: str, prompt: str) -> str:
    path = Path(image_path)

    ext = path.suffix.lower()
    if ext not in MEDIA_TYPES:
        raise ValueError(f"Formato no soportado: {ext}")
    media_type = MEDIA_TYPES[ext]

    file_size = path.stat().st_size
    if file_size > MAX_SIZE_BYTES:
        raise ValueError(f"Archivo excede 5MB: {file_size / 1024 / 1024:.1f}MB")

    with open(path, "rb") as f:
        b64_data = base64.b64encode(f.read()).decode()

    response = client.messages.create(
        model="claude-3-5-sonnet-latest",
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {"type": "base64", "media_type": media_type, "data": b64_data}
                },
                {"type": "text", "text": prompt}
            ]
        }]
    )
    return response.content[0].text

try:
    result = send_image_to_claude("foto.jpg", "¿Qué ves en esta imagen?")
    print(result)
except ValueError as e:
    print(f"Error de validación: {e}")

Ejercicio 2 (Medio): Descargar URL y enviar a Claude

Crea una función claude_from_url(url, prompt) que:

  • Descargue la imagen desde una URL usando httpx
  • Detecte el media_type desde el header Content-Type
  • Valide que sea un formato soportado y no exceda 5MB
  • Envíe a Claude y retorne la respuesta
  • Maneje errores de red (timeout, 404) con mensajes claros
Ver solución
import anthropic
import base64
import httpx

client = anthropic.Anthropic()

ALLOWED_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}

def claude_from_url(url: str, prompt: str, timeout: int = 30) -> str:
    try:
        http_response = httpx.get(url, follow_redirects=True, timeout=timeout)
        http_response.raise_for_status()
    except httpx.TimeoutException:
        raise ConnectionError(f"Timeout descargando imagen: {url}")
    except httpx.HTTPStatusError as e:
        raise ConnectionError(f"Error HTTP {e.response.status_code}: {url}")

    content_type = http_response.headers.get("content-type", "")
    media_type = content_type.split(";")[0].strip()

    if media_type not in ALLOWED_TYPES:
        raise ValueError(f"Tipo no soportado: {media_type}")

    image_bytes = http_response.content
    if len(image_bytes) > 5 * 1024 * 1024:
        raise ValueError(f"Imagen excede 5MB: {len(image_bytes) / 1024 / 1024:.1f}MB")

    b64_data = base64.b64encode(image_bytes).decode()

    response = client.messages.create(
        model="claude-3-5-sonnet-latest",
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {"type": "base64", "media_type": media_type, "data": b64_data}
                },
                {"type": "text", "text": prompt}
            ]
        }]
    )
    return response.content[0].text

try:
    result = claude_from_url(
        "https://example.com/producto.jpg",
        "Describe este producto en español."
    )
    print(result)
except (ConnectionError, ValueError) as e:
    print(f"Error: {e}")

Ejercicio 3 (Medio): Comparador OpenAI vs Claude

Crea una función compare_vision(image_path, prompt) que:

  • Envíe la misma imagen y prompt a GPT-4o Y a Claude 3.5 Sonnet
  • Retorne un diccionario con ambas respuestas y los tokens usados por cada uno
  • Calcule el costo de cada llamada

Necesitarás openai y anthropic instalados.

Ver solución
from openai import OpenAI
import anthropic
import base64
from pathlib import Path

openai_client = OpenAI()
anthropic_client = anthropic.Anthropic()

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

def compare_vision(image_path: str, prompt: str) -> dict:
    ext = Path(image_path).suffix.lower()
    media_type = MEDIA_TYPES.get(ext, "image/jpeg")

    with open(image_path, "rb") as f:
        b64_data = base64.b64encode(f.read()).decode()

    openai_response = openai_client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text", "text": prompt},
                {
                    "type": "image_url",
                    "image_url": {"url": f"data:{media_type};base64,{b64_data}"}
                }
            ]
        }],
        max_tokens=1024
    )

    claude_response = anthropic_client.messages.create(
        model="claude-3-5-sonnet-latest",
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {"type": "base64", "media_type": media_type, "data": b64_data}
                },
                {"type": "text", "text": prompt}
            ]
        }]
    )

    return {
        "openai": {
            "response": openai_response.choices[0].message.content,
            "input_tokens": openai_response.usage.prompt_tokens,
            "output_tokens": openai_response.usage.completion_tokens,
            "cost": (openai_response.usage.prompt_tokens / 1e6 * 2.50 +
                     openai_response.usage.completion_tokens / 1e6 * 10.00),
        },
        "anthropic": {
            "response": claude_response.content[0].text,
            "input_tokens": claude_response.usage.input_tokens,
            "output_tokens": claude_response.usage.output_tokens,
            "cost": (claude_response.usage.input_tokens / 1e6 * 3.00 +
                     claude_response.usage.output_tokens / 1e6 * 15.00),
        },
    }

results = compare_vision("foto.jpg", "Describe esta imagen en 2 oraciones.")
for provider, data in results.items():
    print(f"\n{'='*40}")
    print(f"{provider.upper()}")
    print(f"Respuesta: {data['response']}")
    print(f"Tokens: {data['input_tokens']} in / {data['output_tokens']} out")
    print(f"Costo: ${data['cost']:.4f}")

Resumen

  • Anthropic usa {"type": "image", "source": {"type": "base64", "media_type": "...", "data": "..."}} — distinto al image_url de OpenAI
  • Modelos: Sonnet (default recomendado), Opus (máxima calidad), Haiku (económico)
  • Solo Base64 — no acepta URLs directas (necesitas descargar primero)
  • media_type es explícito y obligatorio
  • max_tokens es obligatorio (en OpenAI es opcional)
  • system prompt va como parámetro top-level, no como mensaje
  • No tiene response_format nativo para JSON — usa prompt engineering
  • Límite de 5MB por imagen
  • Respuesta en response.content[0].text (vs response.choices[0].message.content)

Recursos adicionales

  1. Anthropic Vision — Documentación oficial
  2. Anthropic API Reference — Messages
  3. Claude 3 Model Card
  4. Anthropic Python SDK