Skip to content

Agent example: read bill photos

The job: staff drop photos of bills and receipts into a folder. The agent reads each one, pulls out the fields, checks them, and either passes them to your accounts system or puts them in a review list.

Tools used: image reading and structured JSON on /v1/chat/completions. The GSTIN and the date are checked by your own code.

import base64, re
from datetime import datetime
from pathlib import Path
from openai import OpenAI
from pydantic import BaseModel, Field
client = OpenAI(base_url="https://dummydomain/v1", api_key="YOUR_API_KEY")
GSTIN = re.compile(r"^\d{2}[A-Z]{5}\d{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")
MIME = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}
class Bill(BaseModel):
readable: bool = Field(description="false if the photo is blurred, dark or cut off")
vendor: str
gstin: str | None = Field(description="15-character GSTIN exactly as printed, or null")
bill_number: str
bill_date: str = Field(description="DD-MM-YYYY")
total_inr: float
def read_bill(photo: Path) -> Bill:
image = f"data:{MIME[photo.suffix.lower()]};base64," + base64.b64encode(photo.read_bytes()).decode()
reply = client.chat.completions.create(
model="Smart",
messages=[{"role": "user", "content": [
{"type": "text", "text": "Read this bill. Copy values exactly as printed. Use null for a missing GSTIN."},
{"type": "image_url", "image_url": {"url": image}},
]}],
response_format={"type": "json_schema",
"json_schema": {"name": "Bill", "schema": Bill.model_json_schema(), "strict": True}},
)
return Bill.model_validate_json(reply.choices[0].message.content)
def problems(bill: Bill) -> list[str]:
found = []
if not bill.readable:
found.append("photo not readable")
if bill.gstin and not GSTIN.match(bill.gstin.replace(" ", "")):
found.append("GSTIN format is wrong")
try:
datetime.strptime(bill.bill_date, "%d-%m-%Y")
except ValueError:
found.append("date is not DD-MM-YYYY")
if bill.total_inr <= 0:
found.append("total is missing")
return found
for photo in sorted(Path("inbox").iterdir()):
if photo.suffix.lower() not in MIME:
continue
bill = read_bill(photo)
issues = problems(bill)
if issues:
print("REVIEW", photo.name, issues) # put it in your review list
else:
print("OK ", photo.name, bill.model_dump()) # send it to your accounts system

A real run of the code above, on made-up sample files. Press Replay to watch the steps in order.

Recorded on 07-10-2026 against a ZenithAI test server, running the code above. The times are real.

What went in

inbox/bill-001.png, a clear photo
inbox/bill-001.png, a clear photo
inbox/bill-002-blurred.png, too blurred to read
inbox/bill-002-blurred.png, too blurred to read

What the agent did

  1. POST /v1/chat/completionsSmart1 s

    Smart read the image and returned JSON that matches the Bill schema.

    Request and response
    Request
    {
      "messages": [
        {
          "role": "user",
          "content": [
            {
              "type": "text",
              "text": "Read this bill. Copy values exactly as printed. Use null for a missing GSTIN."
            },
            {
              "type": "image_url",
              "image_url": {
                "url": "<file, 57,193 bytes>"
              }
            }
          ]
        }
      ],
      "response_format": {
        "type": "json_schema",
        "json_schema": {
          "name": "Bill",
          "schema": {
            "properties": {
              "readable": {
                "description": "false if the photo is blurred, dark or cut off",
                "title": "Readable",
                "type": "boolean"
              },
              "vendor": {
                "title": "Vendor",
                "type": "string"
              },
              "gstin": {
                "anyOf": [
                  {
                    "type": "string"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "15-character GSTIN exactly as printed, or null",
                "title": "Gstin"
              },
              "bill_number": {
                "title": "Bill Number",
                "type": "string"
              },
              "bill_date": {
                "description": "DD-MM-YYYY",
                "title": "Bill Date",
                "type": "string"
              },
              "total_inr": {
                "title": "Total Inr",
                "type": "number"
              }
            },
            "required": [
              "readable",
              "vendor",
              "gstin",
              "bill_number",
              "bill_date",
              "total_inr"
            ],
            "title": "Bill",
            "type": "object"
          },
          "strict": true
        }
      }
    }
    Response 200
    {
      "choices": [
        {
          "finish_reason": "stop",
          "index": 0,
          "message": {
            "content": "{\"readable\":true,\"vendor\":\"Sharma Traders\",\"gstin\":\"07AAKFS1234M1Z6\",\"bill_number\":\"INV-2026-0412\",\"bill_date\":\"03-10-2026\",\"total_inr\":118000.0}",
            "role": "assistant"
          }
        }
      ],
      "object": "chat.completion",
      "usage": {
        "completion_tokens": 73,
        "prompt_tokens": 686,
        "total_tokens": 759
      }
    }
  2. POST /v1/chat/completionsSmart0.8 s

    Smart read the image and returned JSON that matches the Bill schema.

    Request and response
    Request
    {
      "messages": [
        {
          "role": "user",
          "content": [
            {
              "type": "text",
              "text": "Read this bill. Copy values exactly as printed. Use null for a missing GSTIN."
            },
            {
              "type": "image_url",
              "image_url": {
                "url": "<file, 64,090 bytes>"
              }
            }
          ]
        }
      ],
      "response_format": {
        "type": "json_schema",
        "json_schema": {
          "name": "Bill",
          "schema": {
            "properties": {
              "readable": {
                "description": "false if the photo is blurred, dark or cut off",
                "title": "Readable",
                "type": "boolean"
              },
              "vendor": {
                "title": "Vendor",
                "type": "string"
              },
              "gstin": {
                "anyOf": [
                  {
                    "type": "string"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "15-character GSTIN exactly as printed, or null",
                "title": "Gstin"
              },
              "bill_number": {
                "title": "Bill Number",
                "type": "string"
              },
              "bill_date": {
                "description": "DD-MM-YYYY",
                "title": "Bill Date",
                "type": "string"
              },
              "total_inr": {
                "title": "Total Inr",
                "type": "number"
              }
            },
            "required": [
              "readable",
              "vendor",
              "gstin",
              "bill_number",
              "bill_date",
              "total_inr"
            ],
            "title": "Bill",
            "type": "object"
          },
          "strict": true
        }
      }
    }
    Response 200
    {
      "choices": [
        {
          "finish_reason": "stop",
          "index": 0,
          "message": {
            "content": "{\"readable\": false, \"vendor\": \"null\", \"gstin\": null, \"bill_number\": \"null\", \"bill_date\": \"null\", \"total_inr\": 0.0}",
            "role": "assistant"
          }
        }
      ],
      "object": "chat.completion",
      "usage": {
        "completion_tokens": 45,
        "prompt_tokens": 686,
        "total_tokens": 731
      }
    }

What the program printed

OK     bill-001.png {'readable': True, 'vendor': 'Sharma Traders', 'gstin': '07AAKFS1234M1Z6', 'bill_number': 'INV-2026-0412', 'bill_date': '03-10-2026', 'total_inr': 118000.0}
REVIEW bill-002-blurred.png ['photo not readable', 'date is not DD-MM-YYYY', 'total is missing']
  1. One bill per request. Your code always knows which answer belongs to which file.
  2. The schema keeps the answer in shape. With strict: true, the JSON always has the fields you asked for, so model_validate_json does not break on a surprise.
  3. readable lets the model say “I can’t see this”. Without it, a model may guess. A clear way out gives you fewer wrong values.
  4. Rules are checked in code. The GSTIN pattern, the date and the total are tested by your program. The model reads; your code checks.

Delivery proofs with a signature and stamp, meter readings, visiting cards, filled paper forms, and photos of a shop shelf for stock counts. Change the schema and the checks; the pattern stays the same.

Common questions

Can ZenithAI read a photo of a bill taken on a phone?

Yes. Send it as an image on /v1/chat/completions with a JSON schema, and you get the vendor, bill number, date, GSTIN and total as fields. A blurred or cut-off photo should be marked unreadable and checked by a person.

How many bill photos can I send in one request?

Up to 8 images and 20 MiB in total per request, each up to 10 MiB. For a pile of bills, send one bill per request. Your code can then match every answer to its file.