Pydantic v2 models for the Sume /v1/videos poll response

Typed Pydantic v2 models for POST and GET /v1/videos: five status literals, an optional error string, a url list, and a guard that stops a bad status early.

5 min readSume
All posts

Model the poll response as one Pydantic v2 class with a Literal status of pending, in_progress, completed, failed or cancelled, an optional string error, and a list of unsigned_urls that defaults to empty. A value outside those five words then fails validation at the edge instead of looping forever in your poller.

Teams that wrapped the removed Sora Videos API (OpenAI lists it as removed on 2026-09-24) often had a hand-written dict reader. A typed model is the cheapest way to make the replacement fail loudly the first time Sume's shape differs from your guess.

The shape to type

Sume documents /v1/videos as OpenRouter-shaped. The submit answers 202 with id, polling_url, status and model. The poll returns the same object plus unsigned_urls once the job completes, a usage object with a cost, and error as a plain string when the job failed. Errors on the HTTP layer use a separate envelope with code, message and request_id.

Fields the model declares (Sume docs, read 2026-10-05)
FieldType in the modelPresent when
idstralways
statusLiteral of five wordsalways
polling_urlstr or Nonesubmit response
errorstr or Nonestatus is failed
unsigned_urlslist of str, default emptystatus is completed
usage.costfloat or Nonewhen the job reports it

The models

The class below ignores unknown keys, which is Pydantic's default, so new fields from Sume do not break you. It does not ignore a new status word, which is deliberate: a status you have not handled should stop the worker, not spin it. To run it, create a virtual environment and pip install pydantic.

from typing import Literal
from pydantic import BaseModel, Field

Status = Literal["pending", "in_progress", "completed", "failed", "cancelled"]

class Usage(BaseModel):
    cost: float | None = None

class VideoJob(BaseModel):
    id: str
    status: Status
    polling_url: str | None = None
    model: str | None = None
    error: str | None = None
    unsigned_urls: list[str] = Field(default_factory=list)
    usage: Usage | None = None

    @property
    def done(self) -> bool:
        return self.status in ("completed", "failed", "cancelled")

    def file_url(self) -> str:
        if self.status != "completed" or not self.unsigned_urls:
            raise ValueError(f"{self.id}: {self.status} {self.error or ''}".strip())
        return self.unsigned_urls[0]

if __name__ == "__main__":
    ok = VideoJob.model_validate({"id": "job_1", "status": "completed",
                                  "unsigned_urls": ["https://example.com/a.mp4"],
                                  "usage": {"cost": 0.63}, "extra": "ignored"})
    print(ok.done, ok.file_url(), ok.usage.cost)
    try:
        VideoJob.model_validate({"id": "job_2", "status": "queued"})
    except Exception as e:
        print(type(e).__name__)

Two guards that earn their place

The done property lists the three terminal states, so a poller loop reads while not job.done. The file_url method raises unless the status is completed and the list is not empty, and the error message carries the job id, status and the vendor's error text. Both are small, and both remove a class of bug where a failed job's missing URL shows up three functions later as a KeyError.

Keep the error envelope separate. A 402 or a 400 arrives with a different body, so parse it with its own small model before you try the job model. Otherwise the validation error you see is about a missing id, not about the credit balance.

  • Use model_validate_json on the raw bytes if you want the parse error to point at the exact field.
  • Add model_config = ConfigDict(extra='forbid') in tests only, to catch fields you did not know about.
  • Store job.id before polling so a restarted worker can resume.

What not to type

Do not type the usage object more than you need. The docs name a cost, and the sample reads only that. Do not add a seed field to the request model: Sume's /v1/videos answers 400 unsupported_parameter for seed and for size, so a request class that offers them invites the error. The request-side checks belong in the pre-flight post linked from this series.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume