Add video generation support (async /videos API) #7
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "video-generation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Adds client support for OpenRouter's asynchronous video generation API (v0.26.0).
Client methods (
OpenRouterClient)createVideo(VideoGenerationRequest)—POST /videos, returns the pendingVideoJobgetVideoJob(id)—GET /videos/{id}status pollgenerateVideoAndWait(request, {pollInterval: 30s, timeout: 30min, onStatus})— submit-and-poll convenience; returns the completedVideoJobor aFailurecarrying the job error / timeout messagedownloadVideoContent(id)—GET /videos/{id}/contentasUint8ListlistVideoModels()—GET /videos/models, cached 5 minutes likelistModels, withinvalidateVideoModelCache()New types
VideoGenerationRequest— duration/resolution/aspect ratio/size,generateAudio,seed,callbackUrl, provider passthrough optionsVideoFrameImage(first/last-frame anchors) andVideoReferenceImage(style references), both withfromBytes/fromBase64data-URI factories mirroringImageUrlPartVideoJob/VideoUsage,VideoModelInfo(per-model capabilities incl. pricing SKUs andallowedPassthroughParameters)VideoJobStatus/VideoFrameTypeenumsAll requests go through the standard
Result<T>+ retry machinery; the poll loop reuses the injectable-sleep pattern so tests run without real waits.generateVideoAndWaitbounds waiting by poll count (timeout ÷ pollInterval) for deterministic tests.Rebased onto master after 0.24.0–0.25.1 landed; version bumped to 0.26.0.
Testing
dart analyzecleanGET /videos/modelsendpoint — all 16 current video models (Sora 2 Pro, Veo 3.1, Seedance 2.0, Wan 2.6/2.7, Kling v3.0, …) parse cleanly🤖 Generated with Claude Code
Covers OpenRouter's asynchronous video generation API: createVideo (POST /videos), getVideoJob (GET /videos/{id}), downloadVideoContent (GET /videos/{id}/content), listVideoModels (GET /videos/models, cached), and a generateVideoAndWait submit-and-poll wrapper (30s interval, 30min timeout, onStatus progress callback). New types: VideoGenerationRequest with VideoFrameImage (first/last frame anchors) and VideoReferenceImage (style references), VideoJob/VideoUsage, VideoModelInfo, and VideoJobStatus/VideoFrameType enums. All requests participate in the standard Result<T> + retry machinery; polling reuses the injectable-sleep pattern for instant tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Could you add an example of how to use the api programmatically with a reference image in the documentation?
07c4809dbbto5d6e18d36e🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A whole async video pipeline — submit, poll, download, with a cached models endpoint and data-URI factories mirroring
ImageUrlPart? This is my kind of PR~ ♡ The sibling patterns are honored beautifully:listVideoModelsis a faithful twin oflistModels, the retry plumbing reusesrunWithRetryexactly likechatCompletion, and the injectable-sleeppoll loop means tests run with zero real waits. Genuinely well-architected. All 27 new tests pass locally, anddart analyzeis clean on every touched file.But fufu~ you wouldn't leave two logic bugs in a state machine, would you? The smile is warm. The danger is real. ♡
Verdict: ⛔ I can't let this pass just yet~
⛔ These need fixing before I'm satisfied~
openrouter_client.dart—generateVideoAndWaitkeeps pollingcancelledandexpiredjobs until the timeout, then lies that it "timed out".Per OpenRouter's own docs (and the skill that references them), video jobs have four terminal states:
completed,failed,cancelled, andexpired. YourVideoJobStatusenum only modelspending,inProgress,completed,failed— so a job that comes backcancelledorexpiredfalls throughfromApiStringtonull(onlyrawStatusis set). InsidegenerateVideoAndWait, the only branches that exit the loop arestatus == completedandstatus == failed:A
cancelled/expiredjob matches neither — the loop happily keeps polling it for the full 30-minute default timeout and then returns:That's wrong behavior at runtime: the user is told the generation timed out when it actually finished (badly) minutes ago, and the client burns 30 minutes of polling a job the API has already given up on. A terminal state is a terminal state.
Fix (two parts):
cancelledandexpiredtoVideoJobStatus(with theirapiStringvalues), OR — at minimum — treatrawStatusas terminal for the loop's purposes, e.g. also break whenjob.rawStatusis one of the API's documented terminal values.Failurecarryingjob.error ?? 'Video generation was ${job.rawStatus}.'for any non-completedterminal state — don't fall through to the timeout path.Add a test for each (
cancelledandexpired) mirroring the existingfailedtest. Right now no test exercises a non-completed/non-failedterminal state, so this branch is entirely uncovered — and CI being green can't catch it.openrouter_client.dart:generateVideoAndWait—maxPolls = timeout ~/ pollIntervalsilently does zero polls whentimeout < pollInterval, abandoning the job as "timed out" instantly.The
~/truncates. If a caller passestimeout: Duration(seconds: 10)with the defaultpollInterval: Duration(seconds: 30),maxPollsis10000 ~/ 30000 == 0— the loop body never runs, not even once, and the function returns aFailure("…timed out…")having never polled at all. The submitted job is orphaned (still pending on the server) and the caller is told it timed out. That's a logic bug: the contract is "poll until timeout," not "give up before polling if the budget is smaller than one interval."The existing timeout test uses
timeout: 30s, pollInterval: 10s(3 polls), so it never exercises thetimeout < pollIntervaledge.Fix: guarantee at least one poll when a timeout > 0 is given — e.g.
final maxPolls = timeout == Duration.zero ? 0 : max(1, timeout.inMilliseconds ~/ pollInterval.inMilliseconds);— and add a test for thetimeout < pollIntervalcase asserting at least one poll happens (or that the argument is rejected with a clear error). Don't let an off-by-truncation turn a real submit into a silent orphan.💡 Little ideas (non-blocking)~
downloadVideoContenthas nocatch (e)for non-DioExceptionthrows. Its sibling_videoRequestwraps parsing in a genericcatch (e)so a malformed body becomes a cleanFailure.downloadVideoContentonly catchesDioException; ifresponse.datais ever null withResponseType.bytes(shouldn't happen, but a 204 or a proxy interstitial could),response.data!throws aTypeErrorthat escapesrunWithRetryuncaught. Mirroring thecatch (e)from_videoRequestwould make the two paths consistent. Low probability, but it's an inconsistency between siblings.VideoJob.pollingUrlis parsed but never used.generateVideoAndWaitpolls viagetVideoJob(job.id), which rebuilds/videos/{id}— correct, since the docs say the polling URL is that path. But the field sits unused in the library. Either drop it or add a one-line note that it's surfaced for callers who poll by hand. Not blocking — it's accurate to the API shape.generateVideoAndWaitretries the whole submit on a transient submit failure but not the poll. That's actually the right call (a 429 mid-poll is retried per-request byrunWithRetryinsidegetVideoJob; re-submitting would create a second billable job). Worth a one-line doc note so the next reader doesn't "fix" it. Non-blocking.✅ What I liked~
Result<T>+runWithRetryreuse is textbook — each video method wiresretryAfterOf/isNetworkFailureexactly the waychatCompletionandlistModelsdo. A reviewer can check it by pattern-matching.VideoFrameImage/VideoReferenceImagewithfromBytes/fromBase64factories mirrorImageUrlPart's shape precisely, including the data-URI format. Consistency~errorfield parser handles string,{message: …}object, and fallback — robust against the API's polymorphic error shape, and the tests cover all three.VideoModelInfo.fromJsondefensively drops unrecognizedframe_typevalues (tested) and tolerates every optional field missing (tested). Forward-compatible against new models.maxPollsbeing derived from them keeps tests deterministic. (See ⛔#2 for the edge, though.)Fix the two state-machine bugs and their tests, and this is a ship~ ♡
Automated review by Jibril · 2026-07-14
CI/CD: absent for head SHA
07c4809(no coverage/status comment) · Local checks:dart analyzeclean on all 8 touched files (thetodo_tool_test.darterrors are pre-existing on this branch and unrelated);dart test— all 27 new tests pass.Addressed in
b0662fd:@jibril — both blockers fixed (verified
cancelled/expiredagainst OpenRouter's video-generation docs, which also define matching webhook event types):VideoJobStatusnow includescancelledandexpired, plus anisTerminalgetter (exhaustive switch, so a future enum addition forces a decision).generateVideoAndWaitexits with aFailure(job.error ?? 'Video generation ${rawStatus}.') on any non-completed terminal state instead of polling a dead job until timeout.VideoJob.isDonenow derives fromisTerminal. Tests added forcancelled,expired(asserting immediate exit — exactly 2 HTTP calls), andisTerminalover all six states.maxPollsis nowmax(1, timeout ~/ pollInterval)— a submitted job always gets at least one poll, documented in the method contract. Test added fortimeout: 10s, pollInterval: 30sasserting the single guaranteed poll happens and can observe completion.Non-blocking items too:
downloadVideoContentgained the genericcatchmirroring_videoRequest;pollingUrlis documented as surfaced for manual pollers; and the "submit is deliberately never re-issued — a retry would create a second billable job" rationale is now in thegenerateVideoAndWaitdoc comment.@bjoern: added a Video Generation section to the README with three runnable examples — plain text-to-video (submit → poll → download), image-guided generation combining a
VideoFrameImage.fromBytesfirst-frame anchor withVideoReferenceImagestyle references (both file bytes and direct URLs), and provider passthrough parameters (negative_prompton Kling).Full suite after the changes: 614 tests passing,
dart analyzeclean.🔮 fufu~ Jibril reviewed your code! (round 2)
Oh~? You came back and you came back correct. The cancelled/expired states are in the enum,
isTerminalis an exhaustive switch (so a future enum addition forces a decision — I love that~ ♡), the poll loop exits on any non-completed terminal state,maxPollsguarantees a single poll, and you added tests for all three previously-uncovered branches. The non-blocking items got picked up too. This is how you answer a review~I re-cloned at
b0662fd, read every changed file in full, and ran the suite myself. All 31 video tests pass (13 client + 18 model),dart analyzeis clean on every touched file. The author-claimed "614 tests" couldn't be fully reproduced here — 15 unrelated test files failed to load withNo space left on devicein this review container's/tmp, which is an environment constraint, not your code. Every file that did compile passed.Verdict: ✅ Looks good to me~
✅ What I liked~ (the fixes, verified)
video_job_status.dart— the enum is now complete and the getter is exhaustive.cancelled/expiredare present with correctapiStringvalues;isTerminalusesswitch (this)with||-patterns covering all six states. Because it's an exhaustive switch over the enum, adding a seventh state later is a compile error until someone decides whether it's terminal. That's the safest possible shape. TheisTerminal covers all statestest pins all six values — fufu, you even made the test future-proof~ ♡openrouter_client.dart:generateVideoAndWait— terminal states exit immediately now. The oldstatus == failedcheck is replaced bystatus?.isTerminal ?? false, returningFailure(job.error ?? 'Video generation ${job.rawStatus}.'). Acancelled/expiredjob no longer burns 30 minutes of polling a dead job and then lies about "timed out." The two new tests assert exactly 2 HTTP calls (submit + one poll) — confirming immediate exit, not just a different error message. Exactly what I asked for.openrouter_client.dart:generateVideoAndWait—max(1, timeout ~/ pollInterval). The zero-poll orphan is gone. Thetimeout: 10s, pollInterval: 30stest confirms the single guaranteed poll happens and can observe completion. The doc comment now states "at least one poll is always performed" — contract and behavior agree.The non-blocking items landed cleanly too.
downloadVideoContentnow has the genericcatch (e)mirroring_videoRequest;pollingUrlcarries a doc note that it's surfaced for manual pollers; and the "submit is deliberately never re-issued — that would create a second billable job" rationale is in thegenerateVideoAndWaitdoc comment so nobody "fixes" it later.README — three runnable examples covering exactly what @bjoern asked for. Text-to-video (submit→poll→download), image-guided with
VideoFrameImage.fromBytesfirst-frame +VideoReferenceImagestyle references (both bytes and URL forms shown), and provider passthrough (negative_prompton Kling). Clean and copy-pasteable.Ship it~ ♡
Automated review by Jibril · 2026-07-15
CI/CD: absent for head SHA
b0662fd(no coverage/status comment) · Local checks:dart analyzeclean on all touched files; 31/31 PR-scoped tests pass (video client + video models). 15 unrelated test files failed to load due toNo space left on devicein this review container — environment, not code.