diff --git a/backend/app/models/ai.py b/backend/app/models/ai.py index ae9902c..a4102e6 100644 --- a/backend/app/models/ai.py +++ b/backend/app/models/ai.py @@ -87,6 +87,9 @@ class VideoFromImageRequest(BaseModel): class VideoResponse(BaseModel): id: str model: str - status: str # "queued" | "processing" | "completed" - video_url: str | None = None + status: str # "queued" | "processing" | "completed" | "failed" + polling_url: str | None = None + video_urls: list[str] | None = None + video_url: str | None = None # first entry of video_urls for convenience + error: str | None = None metadata: dict[str, Any] | None = None diff --git a/backend/app/routers/generate.py b/backend/app/routers/generate.py index 01382f9..2e439d3 100644 --- a/backend/app/routers/generate.py +++ b/backend/app/routers/generate.py @@ -105,11 +105,15 @@ async def generate_video( raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}") + urls = result.get("unsigned_urls") or result.get("video_urls") return VideoResponse( id=result.get("id", ""), - model=result.get("model", body.model), + model=body.model, status=result.get("status", "queued"), - video_url=result.get("video_url"), + polling_url=result.get("polling_url"), + video_urls=urls, + video_url=(urls or [None])[0], + error=result.get("error"), metadata=result.get("metadata"), ) @@ -132,10 +136,39 @@ async def generate_video_from_image( raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}") + urls = result.get("unsigned_urls") or result.get("video_urls") return VideoResponse( id=result.get("id", ""), - model=result.get("model", body.model), + model=body.model, status=result.get("status", "queued"), - video_url=result.get("video_url"), + polling_url=result.get("polling_url"), + video_urls=urls, + video_url=(urls or [None])[0], + error=result.get("error"), + metadata=result.get("metadata"), + ) + + +@router.get("/video/status", response_model=VideoResponse) +async def poll_video_status( + polling_url: str, + _: dict = Depends(get_current_user), +) -> VideoResponse: + """Poll the status of a video generation job via its polling_url.""" + try: + result = await openrouter.poll_video_status(polling_url) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"OpenRouter error: {exc}") + + urls = result.get("unsigned_urls") or result.get("video_urls") + return VideoResponse( + id=result.get("id", ""), + model=result.get("model", ""), + status=result.get("status", "processing"), + polling_url=result.get("polling_url"), + video_urls=urls, + video_url=(urls or [None])[0], + error=result.get("error"), metadata=result.get("metadata"), ) diff --git a/backend/app/services/openrouter.py b/backend/app/services/openrouter.py index 39dc38c..d53c21e 100644 --- a/backend/app/services/openrouter.py +++ b/backend/app/services/openrouter.py @@ -50,6 +50,9 @@ async def chat_completion( "max_tokens": max_tokens, } async with httpx.AsyncClient(timeout=60) as client: + resp = client.build_request( + "POST", f"{base_url}/chat/completions", headers=_headers(), json=payload + ) response = await client.send(resp) response.raise_for_status() return response.json() @@ -90,7 +93,7 @@ async def generate_video( payload["duration_seconds"] = duration_seconds async with httpx.AsyncClient(timeout=120) as client: resp = client.build_request( - "POST", f"{base_url}/video/generations", headers=_headers(), json=payload + "POST", f"{base_url}/videos", headers=_headers(), json=payload ) response = await client.send(resp) response.raise_for_status() @@ -116,8 +119,17 @@ async def generate_video_from_image( payload["duration_seconds"] = duration_seconds async with httpx.AsyncClient(timeout=120) as client: resp = client.build_request( - "POST", f"{base_url}/video/generations/from-image", headers=_headers(), json=payload + "POST", f"{base_url}/videos", headers=_headers(), json=payload ) response = await client.send(resp) response.raise_for_status() return response.json() + + +async def poll_video_status(polling_url: str) -> dict[str, Any]: + """Check the status of a video generation job via its polling_url.""" + async with httpx.AsyncClient(timeout=15) as client: + resp = client.build_request("GET", polling_url, headers=_headers()) + response = await client.send(resp) + response.raise_for_status() + return response.json() diff --git a/backend/tests/test_generate.py b/backend/tests/test_generate.py index efa76d4..4dd0b5c 100644 --- a/backend/tests/test_generate.py +++ b/backend/tests/test_generate.py @@ -29,18 +29,15 @@ FAKE_IMAGE = { FAKE_VIDEO = { "id": "gen-vid-1", - "model": "stability/stable-video", + "polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1", "status": "queued", - "video_url": None, - "metadata": {"estimated_seconds": 30}, } FAKE_VIDEO_DONE = { "id": "gen-vid-2", - "model": "runway/gen-3", + "polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-2", "status": "completed", - "video_url": "https://example.com/video.mp4", - "metadata": None, + "unsigned_urls": ["https://example.com/video.mp4"], } @@ -169,8 +166,8 @@ async def test_generate_video(client): data = resp.json() assert data["id"] == "gen-vid-1" assert data["status"] == "queued" + assert data["polling_url"] == "https://openrouter.ai/api/v1/videos/gen-vid-1" assert data["video_url"] is None - assert data["metadata"]["estimated_seconds"] == 30 async def test_generate_video_unauthenticated(client): @@ -209,6 +206,45 @@ async def test_generate_video_from_image(client): data = resp.json() assert data["status"] == "completed" assert data["video_url"] == "https://example.com/video.mp4" + assert data["video_urls"] == ["https://example.com/video.mp4"] + + +async def test_poll_video_status(client): + token = await _user_token(client) + mock_result = { + "id": "gen-vid-1", + "status": "completed", + "unsigned_urls": ["https://example.com/video.mp4"], + } + with patch("backend.app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, return_value=mock_result): + resp = await client.get( + "/generate/video/status", + params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "completed" + assert data["video_url"] == "https://example.com/video.mp4" + + +async def test_poll_video_status_unauthenticated(client): + resp = await client.get( + "/generate/video/status", + params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, + ) + assert resp.status_code == 401 + + +async def test_poll_video_status_upstream_error(client): + token = await _user_token(client) + with patch("backend.app.routers.generate.openrouter.poll_video_status", new_callable=AsyncMock, side_effect=Exception("timeout")): + resp = await client.get( + "/generate/video/status", + params={"polling_url": "https://openrouter.ai/api/v1/videos/gen-vid-1"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 502 async def test_generate_video_from_image_unauthenticated(client):