DEV Community

Sergey Inozemtsev
Sergey Inozemtsev

Posted on

How to send images and PDFs to OpenAI, Claude, and Gemini APIs using the same Python code

OpenAI, Anthropic's Claude, and Google's Gemini can all process images and PDFs. The annoying part starts when an application needs to support more than one of them: each API expects a different request structure, different field names, and sometimes different capabilities.

This tutorial shows how to send images and PDFs to all three providers through the same Python interface. The examples use llm-api-adapter, a lightweight SDK that calls each provider directly and converts a shared UserMessage into the required wire format.

Repository: github.com/Inozem/llm_api_adapter

Install the library

pip install "llm-api-adapter>=0.5.1"
Enter fullscreen mode Exit fullscreen mode

Version 0.5.1 or later is required for PDF input. Image input was introduced in 0.5.0.

The package does not require the OpenAI, Anthropic, or Google SDKs. It sends requests directly and uses requests as its only runtime dependency.

Configure a provider

Create one adapter for the provider and model you want to call:

import os

from llm_api_adapter.universal_adapter import UniversalLLMAPIAdapter

adapter = UniversalLLMAPIAdapter(
    organization="openai",
    model=os.environ["OPENAI_MODEL"],
    api_key=os.environ["OPENAI_API_KEY"],
)
Enter fullscreen mode Exit fullscreen mode

The file message examples below are independent from that configuration. To use Claude or Gemini, you create another adapter instance, but the UserMessage, ImagePart, and DocumentPart stay unchanged.

Send an image from a URL

The simplest option is an image with a public URL:

from llm_api_adapter.models.messages.chat_message import UserMessage
from llm_api_adapter.models.messages.file_parts import ImagePart

message = UserMessage(
    "Describe this image in one sentence.",
    files=[
        ImagePart(
            url="https://example.com/photo.jpg",
        )
    ],
)

response = adapter.chat(
    messages=[message],
    max_tokens=200,
)

print(response.content)
Enter fullscreen mode Exit fullscreen mode

ImagePart detects the MIME type from the URL extension. In this example, .jpg becomes image/jpeg.

Under the shared interface, the adapter converts this file into the format required by the selected provider. The application does not need separate OpenAI, Claude, and Gemini message builders.

Image URLs without an extension

Some image URLs do not end with a filename:

https://api.example.com/files/42
Enter fullscreen mode Exit fullscreen mode

In that case, pass the MIME type explicitly:

image = ImagePart(
    url="https://api.example.com/files/42",
    media_type="image/jpeg",
)
Enter fullscreen mode Exit fullscreen mode

This avoids guessing from response headers or downloading the file during message serialization.

Send an image from bytes

Use bytes when the image is local, generated by your application, stored behind authentication, or already available in memory:

from llm_api_adapter.models.messages.chat_message import UserMessage
from llm_api_adapter.models.messages.file_parts import ImagePart

with open("photo.png", "rb") as image_file:
    image_bytes = image_file.read()

message = UserMessage(
    "What is shown in this image?",
    files=[
        ImagePart(
            data=image_bytes,
            media_type="image/png",
        )
    ],
)

response = adapter.chat(
    messages=[message],
    max_tokens=200,
)

print(response.content)
Enter fullscreen mode Exit fullscreen mode

When you pass raw bytes, media_type is required. The adapter base64-encodes the data only when building the provider request.

Send multiple images in one message

The files field accepts a list, so the same interface works for comparisons and before-and-after analysis:

message = UserMessage(
    "Compare these two images and list the visible differences.",
    files=[
        ImagePart(url="https://example.com/before.jpg"),
        ImagePart(url="https://example.com/after.jpg"),
    ],
)

response = adapter.chat(
    messages=[message],
    max_tokens=400,
)
Enter fullscreen mode Exit fullscreen mode

The text stays the first part of the user message, followed by the files in the order provided.

Send a PDF from bytes

PDF input uses DocumentPart. The rest of the request is identical to the image examples:

from llm_api_adapter.models.messages.chat_message import UserMessage
from llm_api_adapter.models.messages.file_parts import DocumentPart

with open("report.pdf", "rb") as pdf_file:
    pdf_bytes = pdf_file.read()

message = UserMessage(
    "Summarize this document in five bullet points.",
    files=[
        DocumentPart(
            data=pdf_bytes,
            media_type="application/pdf",
        )
    ],
)

response = adapter.chat(
    messages=[message],
    max_tokens=600,
)

print(response.content)
Enter fullscreen mode Exit fullscreen mode

PDF bytes provide the most portable document path. They work across the supported OpenAI, Anthropic, and Google request formats.

DocumentPart currently represents PDF input rather than every possible document format. This is intentional: a narrow validated contract is more useful than a generic FilePart that appears portable but fails differently for each provider.

Send a PDF from a URL

You can also create a document part from a URL:

message = UserMessage(
    "Extract the main conclusions from this report.",
    files=[
        DocumentPart(
            url="https://example.com/report.pdf",
        )
    ],
)
Enter fullscreen mode Exit fullscreen mode

DocumentPart detects application/pdf from a .pdf URL extension. For a URL without an extension, pass the MIME type explicitly:

document = DocumentPart(
    url="https://api.example.com/files/42",
    media_type="application/pdf",
)
Enter fullscreen mode Exit fullscreen mode

PDF URL support has one important compatibility boundary:

  • Anthropic accepts PDF URLs.
  • Google accepts PDF URLs.
  • OpenAI Responses accepts PDF URLs.
  • OpenAI Chat Completions accepts PDF bytes, but not PDF URLs.

When the selected OpenAI path cannot represent a PDF URL, the adapter raises a clear ValueError instead of silently downloading the file, changing the endpoint, or sending an invalid payload.

For that path, download the file in your application under your own timeout, authentication, size, and security rules, then pass it as bytes.

Switch between OpenAI, Claude, and Gemini

The message is not tied to a provider:

message = UserMessage(
    "Describe this image.",
    files=[ImagePart(url="https://example.com/photo.jpg")],
)
Enter fullscreen mode Exit fullscreen mode

Only the adapter configuration changes:

import os

from llm_api_adapter.universal_adapter import UniversalLLMAPIAdapter

adapters = {
    "openai": UniversalLLMAPIAdapter(
        organization="openai",
        model=os.environ["OPENAI_MODEL"],
        api_key=os.environ["OPENAI_API_KEY"],
    ),
    "anthropic": UniversalLLMAPIAdapter(
        organization="anthropic",
        model=os.environ["ANTHROPIC_MODEL"],
        api_key=os.environ["ANTHROPIC_API_KEY"],
    ),
    "google": UniversalLLMAPIAdapter(
        organization="google",
        model=os.environ["GOOGLE_MODEL"],
        api_key=os.environ["GOOGLE_API_KEY"],
    ),
}

provider = "anthropic"
response = adapters[provider].chat(
    messages=[message],
    max_tokens=200,
)

print(response.content)
Enter fullscreen mode Exit fullscreen mode

You do not rebuild the image or document message when switching providers. This is the main boundary of the abstraction: normalize the portable application-facing input, then serialize it separately for each API.

Compatibility matrix

Input OpenAI Claude Gemini
Image URL Yes Yes Yes
Image bytes Yes Yes Yes
Multiple images Yes Yes Yes
PDF bytes Yes Yes Yes
PDF URL Responses API only Yes Yes

The exact model must also support the requested modality. A valid provider payload cannot make a text-only model accept an image or a document.

Common errors

Passing bytes without a MIME type

This is invalid:

ImagePart(data=image_bytes)
Enter fullscreen mode Exit fullscreen mode

The adapter cannot safely infer a MIME type from arbitrary bytes. Pass it explicitly:

ImagePart(
    data=image_bytes,
    media_type="image/png",
)
Enter fullscreen mode Exit fullscreen mode

The same rule applies to PDF bytes:

DocumentPart(
    data=pdf_bytes,
    media_type="application/pdf",
)
Enter fullscreen mode Exit fullscreen mode

Providing both a URL and bytes

A file part must have one source:

ImagePart(
    url="https://example.com/photo.jpg",
    data=image_bytes,
    media_type="image/jpeg",
)
Enter fullscreen mode Exit fullscreen mode

This is ambiguous and raises an error. Use either url or data.

Using the wrong file class

ImagePart validates image/* MIME types. DocumentPart currently validates application/pdf.

This fails early:

ImagePart(
    data=pdf_bytes,
    media_type="application/pdf",
)
Enter fullscreen mode Exit fullscreen mode

Early validation is preferable to waiting for a provider-specific HTTP error after the request has already been sent.

Sending a PDF URL through OpenAI Chat Completions

Use PDF bytes for that API path, or choose an OpenAI model routed through the Responses API. The adapter does not hide this difference because doing so would change application behavior.

What the adapter normalizes

The shared Python interface hides repetitive payload conversion:

  • OpenAI uses different image and file blocks for Chat Completions and Responses.
  • Anthropic uses image or document content blocks with URL or base64 sources.
  • Google uses fileData for URLs and inlineData for bytes.

The adapter converts ImagePart and DocumentPart into those structures. It does not pretend that every provider supports every source type through every endpoint.

That distinction matters. A useful multi-provider SDK should reduce provider-specific application code, but it should still expose real capability boundaries as explicit errors.

When this approach is useful

This interface is a good fit when:

  • your application calls OpenAI, Claude, or Gemini directly;
  • you want to change providers without rebuilding message objects;
  • you need both URL and in-memory file input;
  • you want a small SDK rather than an agent framework or hosted proxy;
  • you prefer unsupported combinations to fail before the HTTP request.

A provider-native SDK may be a better choice when your application will never switch providers or depends heavily on a provider-specific file storage workflow.

Complete example

The following example sends a local PDF while keeping the provider configurable:

import os

from llm_api_adapter.models.messages.chat_message import UserMessage
from llm_api_adapter.models.messages.file_parts import DocumentPart
from llm_api_adapter.universal_adapter import UniversalLLMAPIAdapter

provider = os.environ.get("LLM_PROVIDER", "openai")

provider_config = {
    "openai": {
        "model": os.environ["OPENAI_MODEL"],
        "api_key": os.environ["OPENAI_API_KEY"],
    },
    "anthropic": {
        "model": os.environ["ANTHROPIC_MODEL"],
        "api_key": os.environ["ANTHROPIC_API_KEY"],
    },
    "google": {
        "model": os.environ["GOOGLE_MODEL"],
        "api_key": os.environ["GOOGLE_API_KEY"],
    },
}

with open("report.pdf", "rb") as pdf_file:
    pdf_bytes = pdf_file.read()

message = UserMessage(
    "Summarize this report in five bullet points.",
    files=[
        DocumentPart(
            data=pdf_bytes,
            media_type="application/pdf",
        )
    ],
)

adapter = UniversalLLMAPIAdapter(
    organization=provider,
    **provider_config[provider],
)

response = adapter.chat(
    messages=[message],
    max_tokens=600,
)

print(response.content)
Enter fullscreen mode Exit fullscreen mode

Change LLM_PROVIDER and the corresponding model and API key variables. The document message remains unchanged.

The goal is not to make three different APIs look identical. It is to keep application code stable where the capabilities are genuinely portable, and make the remaining differences explicit.

The source code is available in the repository:
github.com/Inozem/llm_api_adapter

Top comments (0)