Repository navigation
feat(media)!: stream generated media and bound what is kept - #2396
Conversation
GET /api/v1/generated/{filename} loaded the whole stored file before
answering, Range requests included, and the console's BFF buffered it
again with arrayBuffer(). A generated video can be hundreds of
megabytes, so any member who knew a clip's name could make every
concurrent request hold all of it in both processes. Generated files
were also recorded nowhere: deleting a conversation left its images,
speech and video in storage, with no quota and no retention.
The route now measures the file and reads only the span asked for,
in bounded chunks: from disk for the local backend, by a ranged
GetObject on S3 (BaseFileStorage.size and open_range). Status, type,
disposition, nosniff, the CSP sandbox, Content-Range and 416 are the
same answers as before, and the tenant boundary is unchanged. The BFF
pipes the upstream body through.
Each file an agent stores is now a generated_media row (organization,
conversation where the run had one, size). The quota
(GENERATED_MEDIA_QUOTA_MB, 5120) is checked before each paid
generation call and refused as a tool answer. Files go with their
conversation, after GENERATED_MEDIA_RETENTION_DAYS (90) through the
daily sweep-generated-media job, and with their organization by
prefix. Files written before this change have no row: still served,
neither counted nor swept.
Closes #2310
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d8008ce5c3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Review fixes for #2396. RR-4c19: deleting generated bytes is a job queued in the deleting transaction instead of a spawn_after_commit task. CleanupExternalState gains a defaulted `prefixes` field (a job queued by an older release still parses); the org purge puts generated_<org> into the job it already queues, and a prefix the store refuses fails the job so its retries re-run it. Conversation and project delete (GeneratedMediaService.forget_conversations) drop the rows and queue the paths through queue_external_state_cleanup. Generated media is now row-backed, so generated_ leaves UNREFERENCED_BY_DESIGN and GeneratedMedia.storage_path joins MEDIA_PATH_COLUMNS (its coverage test was failing). Files from before the table have no row and are reported as such, documented in docs/data-protection.md. RR-7d02: the sweep runs each batch in its own worker session, committed before the next is read, and starts no batch after a 10-minute budget (job timeout is 15), so a timeout keeps the work already done. RR-a8e6: no break on a partial batch; expired() reads past a (created_at, id) cursor, so stuck rows are skipped and retried next run. RR-1f5b: keep_generated_media unlinks on BaseException, shielded. RR-6e30: UserService.delete drops the generated rows of the person's cascading conversations (after project chats are handed on) and queues their unlink; the DELETE /users/{id} row says so in four locales. RR-c247: bytes_used counts only rows with a conversation; the refusal names GENERATED_MEDIA_QUOTA_MB and the deployment operator. RR-58d1: docstring and configuration docs say the quota may be passed by up to one file per call in flight. RR-9b7a: CHANGELOG splits streaming (Fixed) from quota, retention and sweep (Added), with the opt-out for operators. Typed feat here; whether the new defaults warrant ! and BREAKING CHANGE is the maintainer's call at squash. RR-0e9d: S3 size() reuses _head_blocking, which returns the length. RR-f41a: comment on why streamed_file_response is not FileResponse. RR-2a6c not done (optional); RR-d3f8 left for its own issue.
RR-6b14: CleanupExternalState leaves an empty `prefixes` out of its dump (`exclude_if`), so a previous-release worker, whose payload forbids extra fields, still runs every cleanup job without prefixes. An org purge's prefix job needs the new worker; CHANGELOG tells operators to upgrade workers with the API and not roll back while one is queued. RR-8c3e: media with no conversation counts against the quota again. The sweep deletes it after UNLINKED_MEDIA_LIFETIME (7 days), whatever GENERATED_MEDIA_RETENTION_DAYS is, 0 included. `expired` takes an `unlinked_cutoff` and an optional `cutoff`. Docs name previews, /btw asides and API runs with no conversation. RR-2d97: a partial prefix delete raises - local when the directory is still there after rmtree, S3 (ExternalServiceError) when delete_objects lists Errors. The best-effort wrappers still log; the cleanup job fails and retries. RR-3c58: a storage error in keep_generated_media's cancel cleanup is logged, so the CancelledError goes on; the double-cancel gap is noted. RR-7e21, RR-a4d6, RR-e07c, RR-91bf: data-protection docs (4 locales) record that a failed per-file unlink is logged, not retried; the orphan note says "mostly" pre-table files; the stale attachment sentence now matches the deletion table; the account-deletion "Leaves" cell names media made with no conversation. Refs #2310
|
@codex review |
🛡️ Codex Security Review · Automatically triggeredSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fe09fa51e0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Addresses three Codex review comments on #2396. P1, quota admission not serialized: each call read SUM(size_bytes) in its own transaction and recorded its file only after the paid call, so concurrent calls all passed. Admission now takes a per-organization advisory lock (LockScope.GENERATED_MEDIA_PER_ORG), measures recorded bytes plus the claims of calls in flight against the quota with this call's estimate, and writes a claim to the new generated_media_reservations table. Recording the file replaces the claim in the same transaction; a refusal, failure or cancellation releases it (MediaSlot, shielded); a claim older than an hour stops counting and the daily sweep removes it. Estimates: 16 MiB per image, 8 KiB per character of speech, 2 MiB per second of video capped at the clip download limit. P1, generated-file unlinks not retried: a deleted conversation's or account's generated media now rides CleanupExternalState.required_paths, whose failures raise so the job retries. The field is defaulted and left out of the dump when empty, like prefixes. P2, filename unique across the deployment: the unique constraint is now (organization_id, filename). No repository query looks a row up by filename alone. Migration 0160_generated_media.py is unreleased and edited in place.
… into fix/2310-stream-generated-media # Conflicts: # CHANGELOG.md # backend/app/agents/capabilities/text_to_speech/_toolset.py # backend/app/agents/capabilities/video_generation/_toolset.py # docs/reference/capabilities.de.md # docs/reference/capabilities.es.md # docs/reference/capabilities.pl.md
test_security_marker.py failed on a96d5c2: three new tests that prove another tenant's generated media is out of reach matched the 'tenant' keyword without @pytest.mark.security, and the sweep's time budget test matched 'budget' by coincidence.
… into fix/2310-stream-generated-media
… into fix/2310-stream-generated-media
## Changes ### Summary Add two capabilities: `text_to_speech`, which reads text aloud through OpenAI, ElevenLabs or Gemini, and `video_generation`, which makes short clips through Gemini (Veo/Omni), BytePlus Seedance, xAI Grok Imagine or Alibaba Wan. Both are provider abstractions shaped like image generation. Each one stores what it makes in the organization's generated-file store, plays it inline in chat, and books its cost to the run budget. ### Business Context Agents could write and draw, but they could not speak or film (#2259, #2260). Authors want the provider, model and spending decisions in the spec. The agent should still be able to pick a voice or a resolution per clip, within limits the author sets. ### Changes - **Catalogs as data.** `core/catalog` gains the speech and video providers and their models, voices, resolutions, aspect ratios, lengths and dated unit prices. `GET /providers/speech-models` and `GET /providers/video-models` expose them to the Builder. - **Clients.** `services/text_to_speech` and `services/video_generation` hold one client per provider. - They classify failures by reason (credentials, policy, rejected, rate limited, unavailable, timed out). Only transient failures are retried. - Every request is bounded by the clip's deadline. - Only the provider's error code is logged (`provider_error_code.py`). The body never reaches the logs, the exception or the model. - The key stays on the provider's own host and is never sent to a storage URL. - **Per-clip choices.** Every setting except the limits is a default the model may override (`_choices.py`, `schema_for`). The tool schema is narrowed to what the chosen model makes, within `max_resolution` and the allowed `voices` list. - An empty `max_resolution` means any resolution. - An empty `voices` list means any voice; on ElevenLabs that is any voice id in the account. - The Builder shows a warning for an empty limit. A refused config names the field it is refused at. - **Budget.** - Before the call, the run budget is asked with the clip's price (`can_afford_ambient_call`). After it, the reported units are booked. - The video clients report the stages a job reached (`submitted`, `made`). Once a job was submitted, a timed-out or cancelled job is booked at its estimate. - A clip that was made but could not be fetched is booked and answered rather than steered, because a retry would be billed again. - A cancelled BytePlus poll cancels the task inside a shielded scope. - **Keys match their provider (#2258).** Image generation now gates its key on the chosen provider, as speech and video do, so the Builder offers only that provider's keys. Publish refuses any capability key whose purpose names a different service than the binding does; `custom` and legacy keys are still accepted. - **Alibaba workspaces (#2350).** A binding can name `alibaba_workspace` and `alibaba_region`. Wan 3.0 is then called at `https://{workspace}.{region}.maas.aliyuncs.com/api/v1`; left empty, the binding keeps the shared DashScope host. The workspace must be a single DNS label, because it becomes part of the host the key is sent to. - **Gemini speech metered by audio (#2351).** Gemini prices carry their token rates: text in, plus 25 audio tokens per second out. Each piece is booked from the length of its returned PCM. The per-character price still checks the budget before the call. - **Starting image.** A clip may start from an image only if this run generated it (`AgentDeps.generated_files`). That set survives a park/resume. In a voice call, video is a `hand_off` capability, and the twin shares the call's set. - **Serving.** Generated audio and video are served inline. Range requests are bounded, and a 416 passes through the Next.js proxy. - **Frontend.** - Provider and model pickers, with the key picker offered before a provider is chosen. - Voice and resolution limits, with warnings. - Audio and video tool-result cards. - Capability art. - Copy in en, pl and de. - **Docs.** `docs/reference/capabilities.md`, `docs/voice.md`, `docs/data-protection.md` and `docs/features.md`, the READMEs, and both capability READMEs, each in four languages. ### Architecture  A clip is booked whenever the provider may have billed it, meaning on every path after `submitted`. Only a refusal before submission is free. ### Verification ```bash cd backend && uv run pytest tests/test_video_generation.py tests/test_video_generation_capability.py \ tests/test_text_to_speech*.py tests/test_hand_off.py tests/test_capability_registry.py -q make lint ``` - 100% coverage on the new platform modules. Both halves of `make lint` pass, as does `check_docs_i18n.py`. - Tested locally with real ElevenLabs and OpenAI keys: the Builder, setting up an agent, and a chat that speaks and plays the clip inline. - Live checks that are still open: a Gemini response blocked with HTTP 200 is classified as `UNAVAILABLE`, and the MP3 length is read from the Xing header. Both are covered against recorded shapes, not against the live APIs. - Not included: an e2e Playwright spec for either capability. The README infographics (`capabilities.webp`, `figures.webp`) still say 29 capabilities and need a redraw; the text lists say 31. ### Linked Issues Closes #2258 Closes #2350 Closes #2351 Refs #2259, #2260, #2309, #2310 ### Folded in: #2395 and #2396 (2026-10-23) This PR now carries both stacked PRs, so it lands and releases as one: - **#2395:** generated media on hosted pages, through signed links. - **#2396:** generated media are streamed, and what is kept is bounded by a per-organization quota and retention. GitHub marks both as merged into this branch. Found while putting them together and fixed here: - **The hosted-page media route streams too.** #2395's `/embed/{public_key}/generated/{filename}` read the whole file for every request. That is exactly what #2396 removed from the console's route, and this route allows 120 fetches a minute per link. It now opens the file (path and size) and streams only the range asked for, like the console's route (`26d7ce4f1`). - **Migration renumbered.** Main shipped `0160` to `0169` in 1.15.0, so `0160_generated_media` is now `0170_generated_media` on `0169_starters_per_membership`. The chain was applied, reverted and reapplied on a scratch pgvector database, with `alembic check` clean. - **The ElevenLabs logo in the speech provider picker.** The picker drew a bare "E": the mark existed among services, not among model providers (`7829eda60`). The sync with main kept both sides in the capability registry (speech and video beside follow-ups), the worker (the media sweep beside the proactive sweeps) and the capability art. Verified locally: 1128 unit tests and 71 integration tests across the merged areas, plus `make lint-backend` and `make lint-frontend`. --------- Co-authored-by: OchnikBartek <b.ochnik70@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Kacper Włodarczyk <kacperwlodarczyk@protonmail.com>
Promotes `[Unreleased]` to `[1.16.0] - 2026-10-23` and bumps `backend/pyproject.toml`, `backend/uv.lock` and `frontend/package.json`. Ships #2346 with #2395 and #2396 folded in: agents speak and make video; generated media load on hosted pages, are streamed, and are bounded by a quota. New capabilities, so MINOR.
Changes
Summary
Generated images, speech and video are now streamed rather than loaded, on the API and in the console's BFF, and a
Rangerequest reads only its range. Each generated file is recorded, counted against a per-organization quota, and deleted with its conversation, after a retention period, and with its organization.Business Context
GET /api/v1/generated/{filename}read the whole file into memory before answering, Range requests included, and the BFF buffered it again witharrayBuffer(). A generated video can be up to 300 MiB, and the route has no rate limiter. So a member who knew a clip's name could make every concurrent request hold the whole clip in both processes. Generated files were also recorded nowhere, so deleting a conversation left its media in storage, with no quota and no retention. Raised by the Codex security review on #2265 and #2267.Changes
Streaming reads
BaseFileStoragegainssize()andopen_range(path, start, end).LocalFileStoragestats the file, then reads the span from disk inSTREAM_CHUNK_BYTESchunks on the file pool.S3FileStorageusesHeadObjectfor the size and a rangedGetObject(Range: bytes=a-b) for the read, through the existing chunked reader.GET /api/v1/generated/{filename}callsopen_generated_media(storage path and size, scoped to the caller's organization) and_workspace_bytes.streamed_file_response. That function decides the span before anything is read and returns aStreamingResponsewithContent-Length. Content type from the suffix allowlist, inline vsdownload,nosniff,CSP: sandbox,Accept-Ranges,Content-Range, the 416 answer and "a multi-range is answered whole" behave as before. Header building is shared withfile_response, which keeps its range parameters for callers that already hold the bytes. A file deleted between the size and the read is still a 404.ctx.organization_id, and a leaf name with a separator is a 404.load_generated_mediakeeps its signature and scoping for callers that need the bytes, such as the video tool's first frame and Generated images, speech and video do not load on hosted pages and embeds #2309.frontend/src/app/api/generated/[filename]/route.ts) passesresponse.bodythrough. It also forwardsContent-Length(only when the body was not content-encoded) andX-Content-Type-Options: nosniff. Range, 200/206/416 and the other headers are unchanged.Bounded and retained
generated_mediatable (migration0160_generated_media.py) with: organization (CASCADE), conversation (SET NULL, linked only when the run's conversation belongs to the same organization), leaf name (unique per organization), storage path, size, andcreated_at.store_generatednow callskeep_generated_media. It saves the bytes and records the row in a session of its own. If the row cannot be written, or the call is cancelled first, it removes the bytes again (shielded, and a failed unlink never replaces the cancellation).GENERATED_MEDIA_QUOTA_MB(default 5120, 0 = off).quota_refusalchecks it ingenerate_image,generate_speechandgenerate_videobefore the paid call. Over the quota, the tool answers in plain text and names the setting an operator can raise. Admission is serialized per organization. Under an advisory lock, a call reserves its estimated size ingenerated_media_reservationsbefore the paid call, so concurrent runs cannot all pass the check. The estimates are 16 MiB for an image, 8 KiB per character for speech, and 2 MiB/s for video, capped at 300 MiB. The stored file replaces the reservation. A failure or cancel releases it, and a reservation left by a crashed call stops counting after an hour and is swept. Every row counts, including media made outside a conversation: previews, test runs, channel/btwasides, and API runs that name no conversation.GENERATED_MEDIA_RETENTION_DAYS(default 90, 0 = off): a new dailysweep-generated-mediajob. Rows with no conversation also go after a fixed 7 days (UNLINKED_MEDIA_LIFETIME), even with retention off.(created_at, id)cursor reads past files the store refuses to delete. Those keep their rows and are retried on the next run.queue_external_state_cleanup), not best-effort after the commit:GeneratedMediaService.forget_conversationsdrops the rows and queues their paths inrequired_paths. A path the store refuses fails the job, which then retries.forget_userruns after project chats are handed to the team, and covers the conversations that cascade with the person.generated_<org>prefix rides the purge's cleanup job (newCleanupExternalState.prefixes). A prefix the store does not fully remove raises, so the job retries; bothdelete_prefixbackends now report a partial delete.prefixesorrequired_pathsis left out of the job payload, so a worker from the previous release still reads every cleanup job that has no prefix.data-protection reportno longer exemptsgenerated_. Generated files now have rows, so the report lists unrecorded files there: mostly files from before this table.data-protection.md(table row, "Generated media" section, deletion and controls rows, the erasure table),configuration.md(new settings) andreference/capabilities.md, with pl/de/es translations restamped. CHANGELOG: streaming under Fixed; quota, retention and sweep under Added, with an operator note.BREAKING CHANGE (MINOR release, per AGENTS.md): by default, generated media is now deleted after 90 days (7 days for media made outside a conversation), and generation is refused past 5 GiB per organization. To keep the previous behaviour, set
GENERATED_MEDIA_RETENTION_DAYS=0andGENERATED_MEDIA_QUOTA_MB=0. Media made outside a conversation still goes after 7 days. Files generated before the upgrade have no row, so they are neither counted nor swept. Upgrade workers together with the API: an organization purge's cleanup job carriesprefixes, which a previous-release worker cannot read, so do not roll back while such a job is queued.Verification
cd backend && uv run pytest tests/test_generated_media.py tests/api/test_generated_media_routes.py tests/test_workspace_bytes.py tests/test_image_generation.py tests/test_video_generation_capability.py tests/test_text_to_speech_capability.py tests/test_retention.py tests/test_services_conversation.py tests/test_project_service.py tests/test_file_storage_s3.py tests/test_worker_main.py tests/test_coverage_gate.py tests/test_media_offload.py tests/api/test_workspace_routes.py tests/integration/test_generated_media.py -q: 534 passed, 12 skipped. The skipped tests are the new integration file, because there was no database.--cov=app,app/services/generated_media.py,app/worker/tasks/generated_media_tasks.py,app/api/routes/v1/generated_media.py,_workspace_bytes.pyand the touched capability modules are at 100%.app/repositories/generated_media.pyand the model's__repr__are covered only bytests/integration/test_generated_media.py, which CI runs.cd frontend && bunx vitest run src/app/api/binary-routes.test.ts: 72 passed. This includes a test where the upstream body never ends: a route that read the whole body would hang there.make lint-backendandmake lint-frontendpass.Not verified locally:
make db-check,test_migrations.pyand the integration tests (repository SQL, theSET NULLandCASCADEbehaviour, the retention union query) were not run. CI runs them.Review: two rounds of structured review (correctness, security, quality, verification) found and fixed these:
Each fix has a regression test (
test_generated_media.py,test_external_state_cleanup.py,test_file_storage*.py,test_org_purge_reservation.py,test_services.py).Limitations:
Linked Issues
Closes #2310
Refs #2346