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.

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.
| Field | Type in the model | Present when |
|---|---|---|
| id | str | always |
| status | Literal of five words | always |
| polling_url | str or None | submit response |
| error | str or None | status is failed |
| unsigned_urls | list of str, default empty | status is completed |
| usage.cost | float or None | when 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
- Python async generator that yields Sume job status until terminal
Write an async generator in stdlib Python that polls /v1/jobs/:id/status, honours next_poll_after_seconds, and stops at a terminal status. No SDK needed.
- Python: price a Seedance 2.5 clip from seconds and resolution
A 17-line function turns resolution and seconds into video tokens and a Sume price: 4 s at 480p is $1.07, 30 s at 1080p is $42.65.
- Python: quote one 10-second clip across five Sume video models
A Python script that prices a 10-second clip on Seedance 2.5, Omni, Wan 3.0, H3 and H3 Max with Sume's 1.25 multiple and per-job rounding. Run it before a test.
- Python requests Retry on POST: safe for Sume only with a key
urllib3 Retry skips POST by default. Allow it for a Sume submit only when every request carries an Idempotency-Key, and retry just 429 and 503.
Written by Sume