← Back to Blog
Tutorial

Vision API tutorial: send images to an LLM, free model included

FastMetal

The short version

  • Change content from a string to an array of parts, and add an image_url part. The endpoint, the key and the SDK stay the same as for text.
  • Use a base64 data URI for local or private images. A public URL works only if the model's host can fetch it.
  • Images are expensive in tokens. An 8x8 pixel PNG measured 1,089 image tokens.
  • gemini-flash-lite-free reads images and costs nothing, so you can test with an empty balance.

Make content an array

A text-only message carries a string:

{"role": "user", "content": "Describe this image"}

To add an image, replace the string with a list of parts:

{
  "role": "user",
  "content": [
    {"type": "text", "text": "Describe this image"},
    {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KG..."}}
  ]
}

Each part has a type of text or image_url, in any order. You can send an image with no text part, but results are more consistent when you say what you want.

Data URI or public URL

image_url.url takes two forms:

FormExampleUse it for
Data URIdata:image/png;base64,<base64>Local files, private images
Public URLhttps://example.com/photo.jpgImages already on the web

The data URI is the reliable choice. A public URL has to be reachable from the model host's servers, so anything behind a login or on an internal network fails. For local files, use a data URI.

Match the MIME type in the data URI to the real format (image/jpeg, image/webp and so on). A mismatch can get the request rejected.

Working code

Python

import base64
import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["FASTMETAL_API_KEY"],
    base_url="https://api.fastmetal.ai/v1",
)

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

resp = client.chat.completions.create(
    model="gemini-flash-lite-free",
    max_tokens=300,
    messages=[
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "What is in this image?"},
                {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
            ],
        }
    ],
)
print(resp.choices[0].message.content)
print(resp.usage)

curl

B64=$(base64 -i photo.png | tr -d '\n')
curl https://api.fastmetal.ai/v1/chat/completions \
  -H "Authorization: Bearer $FASTMETAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"model\": \"gemini-flash-lite-free\",
    \"max_tokens\": 300,
    \"messages\": [{
      \"role\": \"user\",
      \"content\": [
        {\"type\": \"text\", \"text\": \"Describe this image\"},
        {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,$B64\"}}
      ]
    }]
  }"

On macOS, base64 -i reads the file. On Linux, use base64 -w0 photo.png.

Images cost a lot of tokens

This is the part that matters in production. The model turns each image into tokens, and you pay for them as input.

We sent one 8x8 pixel PNG to gemini-flash-lite-free on August 29, 2026. The usage block:

{
  "prompt_tokens": 1098,
  "completion_tokens": 1,
  "prompt_tokens_details": {
    "text_tokens": 9,
    "image_tokens": 1089
  }
}

Nine text tokens, 1,089 image tokens, for an image of 64 pixels. Each image has a large token floor, however few pixels it has.

What follows from that:

  • One image costs about as much as several paragraphs of text.
  • The Chat Completions API is stateless, so an image left in the conversation history is sent, and billed, again on every turn.
  • In an image-heavy app, include the image only in the turn that needs it, and keep the model's text description in the history after that.

prompt_tokens_details.image_tokens comes back in the real response, so measure your own images once. Paid models bill input per million tokens; rates are on the pricing page.

Several images in one request

Add more image_url parts to the same array. This is how you ask for a comparison:

content = [{"type": "text", "text": "List the differences between these two images"}]
for path in ["before.png", "after.png"]:
    with open(path, "rb") as f:
        b64 = base64.b64encode(f.read()).decode()
    content.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}})

Each image adds its own image tokens.

Which models accept images

Not every model reads images. A model without vision support either returns an error or quietly ignores the image and answers from the text alone. The quiet case is the harder one to catch.

The model catalog shows image input support for each model. Most of the large Claude, Gemini, GPT, Grok and Qwen models accept images, and so does the free gemini-flash-lite-free.

A model can support vision while a particular host's endpoint for it does not. Before you trust a catalog flag for production, send one real image and check the answer.

Common mistakes

  • Leaving content as a string. There is no place for the image until it is an array.
  • Dropping the data:image/png;base64, prefix. Raw base64 alone is rejected. Put no space after the comma.
  • Line breaks in the base64. Some base64 builds wrap their output; strip it with tr -d '\n' or use -w0.
  • Sending full-resolution photos. Scale the image down as far as the detail you need allows; it saves tokens and upload time.
  • Treating PDFs as images. The web chat accepts PDF attachments, but the API format for PDFs differs by model.

Where to go next

To use what the model reads from an image in code, ask for JSON with structured output.

For agents, the FastMetal MCP server has an analyze_image tool that does the same job; setup is in the MCP guide.

FAQ

Can I try image input for free?

Yes. gemini-flash-lite-free accepts images and costs nothing, so it works on an account with no balance. Free models are rate-limited.

How many tokens does one image use?

It depends on the model, but even a tiny image can cost around a thousand tokens: an 8x8 PNG measured 1,089. Read prompt_tokens_details.image_tokens in the response for your own numbers.

Should I send a URL or base64?

Base64 in a data URI is the reliable choice for local and private images. A public URL must be reachable from the model host's servers.

The model answers as if it never saw the image. Why?

The model probably does not support image input. Check the model catalog.

Try it

Create a free account, issue a key, and send one image to gemini-flash-lite-free. The same request runs on a paid vision model when you change model.

Try the latest AI models right now

the latest AI models is available on FastMetal through one API key. Start in the browser, or call it from the OpenAI SDK.