Skip to content

feat(media)!: stream generated media and bound what is kept - #2396

Merged
DEENUU1 merged 8 commits into
feat/2259-2260-speech-and-videofrom
fix/2310-stream-generated-media
Oct 23, 2026
Merged

DEENUU1 merged 8 commits into
feat/2259-2260-speech-and-videofrom
fix/2310-stream-generated-media

Conversation

@MichalZelasko

@MichalZelasko MichalZelasko commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Changes

Summary

Generated images, speech and video are now streamed rather than loaded, on the API and in the console's BFF, and a Range request 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 with arrayBuffer(). 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

  • BaseFileStorage gains size() and open_range(path, start, end). LocalFileStorage stats the file, then reads the span from disk in STREAM_CHUNK_BYTES chunks on the file pool. S3FileStorage uses HeadObject for the size and a ranged GetObject (Range: bytes=a-b) for the read, through the existing chunked reader.
  • GET /api/v1/generated/{filename} calls open_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 a StreamingResponse with Content-Length. Content type from the suffix allowlist, inline vs download, nosniff, CSP: sandbox, Accept-Ranges, Content-Range, the 416 answer and "a multi-range is answered whole" behave as before. Header building is shared with file_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.
  • The tenant boundary is unchanged: the directory comes from ctx.organization_id, and a leaf name with a separator is a 404. load_generated_media keeps 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.
  • The BFF route (frontend/src/app/api/generated/[filename]/route.ts) passes response.body through. It also forwards Content-Length (only when the body was not content-encoded) and X-Content-Type-Options: nosniff. Range, 200/206/416 and the other headers are unchanged.

Bounded and retained

  • New generated_media table (migration 0160_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, and created_at.
  • store_generated now calls keep_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_refusal checks it in generate_image, generate_speech and generate_video before 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 in generated_media_reservations before 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 /btw asides, and API runs that name no conversation.
  • GENERATED_MEDIA_RETENTION_DAYS (default 90, 0 = off): a new daily sweep-generated-media job. Rows with no conversation also go after a fixed 7 days (UNLINKED_MEDIA_LIFETIME), even with retention off.
    • Each batch commits in its own worker session, so a timeout keeps the batches already done.
    • A (created_at, id) cursor reads past files the store refuses to delete. Those keep their rows and are retried on the next run.
    • A 10-minute time budget stops new batches before the 15-minute job timeout.
  • Deletion is queued as a job in the deleting transaction (queue_external_state_cleanup), not best-effort after the commit:
    • Conversation, project and a2a deletion: GeneratedMediaService.forget_conversations drops the rows and queues their paths in required_paths. A path the store refuses fails the job, which then retries.
    • Account deletion: forget_user runs after project chats are handed to the team, and covers the conversations that cascade with the person.
    • Organization deletion: the generated_<org> prefix rides the purge's cleanup job (new CleanupExternalState.prefixes). A prefix the store does not fully remove raises, so the job retries; both delete_prefix backends now report a partial delete.
    • The retention sweep's conversation purge unlinks generated media and drops its rows too.
  • An empty prefixes or required_paths is left out of the job payload, so a worker from the previous release still reads every cleanup job that has no prefix.
  • data-protection report no longer exempts generated_. Generated files now have rows, so the report lists unrecorded files there: mostly files from before this table.
  • Docs: data-protection.md (table row, "Generated media" section, deletion and controls rows, the erasure table), configuration.md (new settings) and reference/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=0 and GENERATED_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 carries prefixes, 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.
  • The two fix commits ran 803 and 670 targeted tests respectively, all passing.
  • With --cov=app, app/services/generated_media.py, app/worker/tasks/generated_media_tasks.py, app/api/routes/v1/generated_media.py, _workspace_bytes.py and the touched capability modules are at 100%. app/repositories/generated_media.py and the model's __repr__ are covered only by tests/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-backend and make lint-frontend pass.

Not verified locally:

  • No database was available. The migration, make db-check, test_migrations.py and the integration tests (repository SQL, the SET NULL and CASCADE behaviour, the retention union query) were not run. CI runs them.
  • Ranged S3 reads were not tested against a real bucket or MinIO, only against a mocked client.
  • Memory use with a real 200 MB clip was not measured. The tests show that only the requested span is read and that the BFF does not wait for the end of the body.

Review: two rounds of structured review (correctness, security, quality, verification) found and fixed these:

  • durable deletion;
  • a sweep that rolled back on timeout, or stalled on one stuck file;
  • a cancelled save that left a file behind;
  • account erasure;
  • the quota exemption;
  • skew between job versions;
  • partial prefix deletes.

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:

  • Files generated before this change have no row. They are still served, but they are not counted or swept and they go only with their organization. The data-protection report lists them.
  • Account erasure reaches media through the person's conversations. Media they made with no conversation goes after 7 days, not at once, because the table has no user column.
  • A file the store still refuses after the cleanup job's last attempt is left without a row, and the data-protection report lists it.
  • Pre-existing and left for follow-ups: the retention purge reads the expiring conversations twice. A queued API run also discards the conversation it opens.

Linked Issues

Closes #2310
Refs #2346

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
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T12:47:52.474427Z fe09fa5 Manual request
🔒 Security Review ✅ Completed 2026-10-22T12:45:16.887528Z fe09fa5 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread backend/app/services/generated_media.py Outdated
Comment thread backend/app/services/generated_media.py Outdated
Comment thread backend/app/services/generated_media.py
Comment thread backend/app/services/generated_media.py Outdated
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.
@MichalZelasko MichalZelasko changed the title fix(media): stream generated media and bound what is kept feat(media)!: stream generated media and bound what is kept Oct 22, 2026
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
@MichalZelasko

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: fe09fa51e0

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread backend/app/db/models/generated_media.py Outdated
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.
@MichalZelasko
MichalZelasko requested a review from DEENUU1 October 23, 2026 08:47
@DEENUU1
DEENUU1 merged commit 1a03242 into feat/2259-2260-speech-and-video Oct 23, 2026
13 checks passed
@DEENUU1
DEENUU1 deleted the fix/2310-stream-generated-media branch October 23, 2026 14:22
DEENUU1 added a commit that referenced this pull request Oct 23, 2026
## 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

![Sequence of a video_generation call: author limits narrow the schema,
the run budget is asked before submitting, the client reports submitted
and made stages, and each failure path is either booked at the estimate
or answered to the
model](https://github.com/user-attachments/assets/8ab919bf-1fa4-456f-8c33-07604cfc59e0)

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>
@DEENUU1 DEENUU1 mentioned this pull request Oct 23, 2026
DEENUU1 added a commit that referenced this pull request Oct 23, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants