Skip to content

Commit e5f6c45

Browse files
committed
release: v1.10.0 — image/video/audio generation + multi-pass security & perf audit
Docs/manifest sync for the 1.10.0 release. The code itself was shipped across the 14 commits between e26da51 and fc5b411 (image rewrite, video async routing, audio modalities+pcm16, the 5-agent audit + fix batches A through E). function.json - version 1.9.0 → 1.10.0 - updated_at bumped to 2026-05-30 - description expanded to list image/video/audio generation, SSRF guard, byte caps, MIME whitelists, citation scheme filter, cached-credit footer, cached auth header, atomic routing-set swap, HTTPAdapter pool sizing - new tags: image-generation, video-generation, audio-generation CHANGELOG.md - new [1.10.0] section with Added / Changed / Fixed / Security / Tests blocks covering every behavior change since 1.9.0 README.md - tagline mentions video-generation and native media rendering - Features list adds entries for image gen, video gen (with supported model families), audio gen, SSRF-guarded downloads - FAQ section on media gen rewritten — explains the routing per output modality (chat/completions vs /videos), the audio modality injection + pcm16 → WAV wrap for openai/gpt-audio, byte caps, MIME whitelist, openrouter.ai-only download guard - test count 727 → 868 in three places TESTING.md / CONTRIBUTING.md / PR template - 727 → 868 test count .gitignore - .claude/, .claude-flow/, .swarm/, .remember/, .mypy_cache/, ruvector.db, browser smoke-test screenshot/HTML/MD artifacts (NNN-rendered, NNN-loaded, NNN-*.html etc.)
1 parent fc5b411 commit e5f6c45

7 files changed

Lines changed: 128 additions & 22 deletions

File tree

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
## Testing
2727

28-
- [ ] All unit tests pass (`python test_pipe.py`727/727 ✓)
28+
- [ ] All unit tests pass (`python test_pipe.py`868/868 ✓)
2929
- [ ] New tests added for the changes
3030
- [ ] Integration tests pass (`python integration_test.py`) — if applicable
3131
- [ ] `CHANGELOG.md` updated under `[Unreleased]`

.gitignore

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,26 @@ nul
5252
.worktrees/
5353
.security-key
5454
logs/security/
55+
56+
# Claude Code / claude-flow / swarm CLI per-project state
57+
.claude/
58+
.claude-flow/
59+
.swarm/
60+
61+
# Remember plugin history
62+
.remember/
63+
64+
# mypy cache
65+
.mypy_cache/
66+
67+
# RuVector runtime DB (built locally by analysis agents, never committed)
68+
ruvector.db
69+
70+
# Browser-driven smoke-test artifacts (Chrome MCP saves screenshots/HTML
71+
# captures keyed by frame number — purely local debug output)
72+
[0-9][0-9][0-9]-*.html
73+
[0-9][0-9][0-9]-*.png
74+
[0-9][0-9][0-9]-*.md
75+
[0-9][0-9][0-9]-*.txt
76+
[0-9][0-9][0-9]-rendered
77+
[0-9][0-9][0-9]-loaded

CHANGELOG.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.10.0] — 2026-05-30
11+
12+
### Added
13+
14+
- **Image generation rendering (flux, gemini-image-preview, ...)**`choices[0].message.images` and `delta.images` data-URL payloads are decoded, uploaded through Open WebUI's file-upload helper, and the message content is rewritten to `![Generated image](/api/v1/files/<id>/content)` so the chat client embeds them inline. Works for both the streaming path (`_wrap_stream` materializes after the SSE loop) and the non-streaming path (`_emit_image_files`); also wired into the native-tool loop so flux+tools no longer drops the image.
15+
- **Video generation routing (veo, kling, sora, seedance, hailuo, wan, grok-imagine)** — video-output models from OpenRouter's catalog (architecture `output_modalities=["video"]`) are routed to the asynchronous `POST /api/v1/videos` endpoint instead of `/chat/completions` (which 500s for them). The pipe submits the job, polls the returned `polling_url` every `VIDEO_POLL_INTERVAL` (default 5 s) up to `VIDEO_GENERATION_TIMEOUT` (default 600 s), downloads the MP4 from `unsigned_urls[0]`, re-hosts it through OWUI, and embeds via the block-HTML token `<div><video>URL</video></div>` — the only emission shape OWUI's `Markdown.svelte` HTML-token renderer accepts for inline video. Forwards `duration`, `resolution`, `aspect_ratio`, `generate_audio`, `seed` from the body. Status emits dedupe on identical labels so long jobs don't pollute the status history.
16+
- **Audio generation routing (lyria, gpt-audio, gpt-audio-mini)** — audio-output models are served via `/chat/completions` with `modalities=["text","audio"]` + `audio={format,voice}` + `stream=true` (the only combination that actually returns audio bytes). For OpenAI's gpt-audio family the format is forced to `pcm16` (the upstream only accepts pcm16 with stream=true) and the raw PCM bytes are wrapped in a minimal RIFF/WAVE container (24 kHz mono) before upload so the browser can play them via `<audio>`. Other providers (Lyria etc.) get mp3 by default; the format flows through `state["audio_format"]` so the materializer picks the correct MIME per request. Embedded as `<div><audio>URL</audio></div>` (same block-HTML token trick as video).
17+
- **`VIDEO_GENERATION_TIMEOUT` + `VIDEO_POLL_INTERVAL` valves** — bound the async polling loop; both per-user-overridable via UserValves.
18+
- **`AUDIO_OUTPUT_FORMAT` + `AUDIO_OUTPUT_VOICE` valves** — admin default for the audio container (mp3 / wav / flac / opus / ogg / aac / m4a / pcm16) and the voice id (gpt-audio family only; music models ignore it). Both per-user-overridable.
19+
- **SSRF / auth-leak guard for media downloads** — new `_is_openrouter_url` helper restricts the polling URL and the unsigned download URL to `openrouter.ai` (or `*.openrouter.ai`); a compromised relay or malicious upstream JSON can no longer redirect the bearer-bearing GET to an attacker-controlled host. The polling URL falls back to the canonical `/videos/<id>` form; the download is refused outright if the host isn't OpenRouter.
20+
- **Byte-size caps + MIME whitelists for media**`_VIDEO_MAX_BYTES=100 MiB` / `_AUDIO_MAX_BYTES=50 MiB`. Video downloads use streaming `iter_content` with a Content-Length early-reject and a mid-stream cap so a hostile upstream cannot exhaust OWUI worker memory with a multi-GB blob. The post-download MIME is restricted to a per-modality whitelist (mp4/webm/mov/mkv for video, mpeg/wav/flac/ogg/opus/aac/mp4 for audio) so a spoofed `Content-Type` can't coerce OWUI's renderer.
21+
- **Citation URL scheme filter**`_emit_citation_events` refuses to emit events for non-`http(s)` URLs, closing the `javascript:` / `data:` / `vbscript:` XSS surface through citation-card rendering.
22+
23+
### Changed
24+
25+
- **Body never mutated**`pipe()` now deep-copies the incoming body before injecting audio-modality flags, so the OWUI-owned dict (reused for history, title generation, etc.) is never touched by the pipe's per-request payload prep. Closes a CRIT correctness bug where a subsequent non-audio chat in the same OWUI session would inherit stale `modalities=["text","audio"]`.
26+
- **Atomic routing-set swap**`pipes()` now builds the audio/video `frozenset`s locally during a refresh and assigns them in one statement, instead of `.clear()` + re-populate. A concurrent `pipe()` call mid-refresh sees either the old set or the new one, never an empty intermediate state.
27+
- **Lazy populate runs off the event loop** — first request after a container restart triggers `self.pipes()` via `asyncio.to_thread`, gated by a `_lazy_populated` flag so it doesn't repeat on every subsequent request when the user happens to have zero audio/video models.
28+
- **Cached credit footer** — stream / non-stream / tool-stream / tool-nonstream footers now read the credit balance from cache only (`_credit_balance_cached`); a pre-warm coroutine (`_prefetch_credit_if_enabled`) runs the HTTP fetch via `asyncio.to_thread` BEFORE the footer is yielded. Previously a cold `SHOW_REMAINING_CREDIT` fetch could stall the SSE stream finalize.
29+
- **Cached Authorization header**`_build_headers` no longer runs `EncryptedStr.decrypt()` (Fernet, ~100 µs) on every chunk send / poll / footer. The decrypted `Bearer …` line is cached keyed on the encrypted ciphertext and auto-invalidates on key rotation. Bounded to 32 entries.
30+
- **HTTP connection pool sized for concurrent users** — the shared `requests.Session` now mounts an `HTTPAdapter(pool_connections=64, pool_maxsize=64, max_retries=0)`. Default urllib3 pool of 10 was a hard limit on concurrent streamed chats; `max_retries=0` because `_retryable_request` already drives Retry-After-aware retries.
31+
- **`_effective_valves` fast path** — when `__user__` has no UserValves attached (the overwhelmingly common case), return `self.valves` directly instead of pydantic-copying + per-key validating. Admin valves are treated as read-only by the rest of the pipe.
32+
- **`_models_cache_valid` short-circuit** — compare the cheap presence + TTL first, only pay the Fernet-decrypt + SHA256 cost of `_build_cache_key` when both match and we're about to serve from cache.
33+
- **Shared `_owui_upload_bytes` helper** — image, video, and audio uploads now share one helper with consistent error logging (`Image upload / Video upload / Audio upload to OWUI failed`), replacing three duplicated 30-line copies of the OWUI helper import + user-resolve + upload-image dance.
34+
- **Module-level constants**`_DATA_IMAGE_RE`, `_VIDEO_MAX_BYTES`, `_AUDIO_MAX_BYTES`, `_VIDEO_MIME_WHITELIST`, `_AUDIO_MIME_WHITELIST`, `_AUDIO_FORMAT_TO_MIME`, `_CITATION_ALLOWED_SCHEMES` — hoisted from per-call locals so the hot path doesn't pay re-compile / re-allocate costs.
35+
- **SSE reads use `iter_lines(chunk_size=8192)`** — fewer syscalls on high-throughput streams than the default 512.
36+
- **Tool-loop non-stream cap-reached branch** — now catches `Timeout` and `HTTPError` explicitly before the generic `Exception`, so a network timeout on the very last call surfaces the friendly `Request timed out after Xs` wrap instead of the sanitized `Internal request error` message. Also builds a clean `{role, content, tool_calls}` assistant message instead of forwarding the raw upstream dict (which can carry `refusal` / legacy `function_call` / vendor reasoning blobs that some downstream models reject on re-submission).
37+
- **Video polling status emit dedupe** — repeated `Generating video (pending)…` labels are suppressed; the operator only sees a new status line when the upstream `status` field changes.
38+
39+
### Fixed
40+
41+
- **`_run_video_generation` resource leaks**`submit_resp` and per-iteration `poll_resp` are wrapped in `try/finally` with explicit `close()`; previously each unhappy branch leaked a socket and a long-running job could leak up to 120 connections.
42+
- **Video error mapping** — 402 maps to `Insufficient credits (HTTP 402)`, 429 to `Rate limited (HTTP 429)`; previously both fell through to a generic `Failed to start video job` that hid the cause.
43+
- **Non-stream `status: done` event always emitted**`_non_stream_with_events` call now wrapped in `try/finally` so the shimmer line clears even on an unhandled exception (matches the stream and video paths).
44+
- **`ZDR_ENFORCE` removed from `UserValves`** — privacy policy is admin-only; a regular user can no longer flip it off through the per-user override.
45+
- **Generic exception sanitization**`_stream_response` and `_non_stream_fetch` no longer surface raw Python exception text to the chat client on a non-`requests.RequestException` failure. Detail goes to server stdout, client gets `Internal stream/request error (see server logs)`. Network-level `RequestException` messages still surface verbatim (operator-safe, useful debug).
46+
- **Audio payload regression** — when `_stream_one_round` enters the tool loop with an audio-output model, captured `delta.audio.data` is now surfaced via `state["audio_b64"]` so `_run_tools_stream` can materialize and embed it. Previously a flux / lyria / gpt-audio model with tools enabled would silently drop the media.
47+
48+
### Security
49+
50+
- All four media-related changes above are also security-relevant: SSRF whitelist, byte-size caps, MIME whitelists, citation URL scheme filter, sanitized internal exceptions, admin-only ZDR.
51+
52+
### Tests
53+
54+
- Suite grew from 727 to **868 tests**: +141 tests covering image / video / audio routing, the SSRF whitelist, atomic frozenset swap, body deepcopy isolation, ZDR + media combo, generic-exception sanitization, the auth-header cache (decrypt-once + key rotation + 32-cap eviction), the `_AUDIO_FORMAT_TO_MIME` table, user-supplied `audio` and `modalities` preservation, base64 padding edge cases, video error branches (Timeout / ConnectionError / mid-poll RequestException / deadline expiry / malformed `unsigned_urls` / non-dict failed-status error / forwarded knobs / Content-Type fallback), and the tool-nonstream cap-branch Timeout path. All green on Python 3.10–3.13.
55+
1056
## [1.9.0] — 2026-05-29
1157

1258
### Added

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ By participating, you agree to uphold this code.
5555

5656
| Command | Description |
5757
| --- | --- |
58-
| `python test_pipe.py` | Run the full unit test suite (727 tests) |
58+
| `python test_pipe.py` | Run the full unit test suite (868 tests) |
5959
| `python integration_test.py` | Run live API tests (requires `OPENROUTER_API_KEY`) |
6060

6161
## Deliverable-PR playbook
@@ -74,7 +74,7 @@ of value. The playbook:
7474
incidental refactors.
7575

7676
3. **Add or update tests.** A change without test coverage needs a written justification
77-
in the PR body. The unit test suite must remain at 727/727.
77+
in the PR body. The unit test suite must remain at 868/868.
7878

7979
4. **Validate locally** using the same commands CI runs:
8080

README.md

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
[![Python](https://img.shields.io/badge/Python-%E2%89%A53.10-blue)](https://www.python.org/)
55
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
66

7-
Access the **full OpenRouter catalog (400+ models)** — chat, TTS, audio, image-generation, and
8-
embedding models — directly inside Open WebUI, with provider routing, reasoning tokens, streaming,
9-
fallbacks, and cache control out of the box.
7+
Access the **full OpenRouter catalog (400+ models)** — chat, TTS, audio (input + generation),
8+
image-generation, video-generation, and embedding models — directly inside Open WebUI, with
9+
provider routing, reasoning tokens, streaming, fallbacks, native media rendering, and cache
10+
control out of the box.
1011

1112
## Table of Contents
1213

@@ -35,7 +36,11 @@ fallbacks, and cache control out of the box.
3536

3637
## Features
3738

38-
- **Manifold pipe** — exposes the full OpenRouter catalog (chat, TTS, audio, image, embeddings) as native Open WebUI models in the model selector. Configurable via `OUTPUT_MODALITIES` and `MODEL_CATEGORY`.
39+
- **Manifold pipe** — exposes the full OpenRouter catalog (chat, TTS, audio, image, video, embeddings) as native Open WebUI models in the model selector. Configurable via `OUTPUT_MODALITIES` and `MODEL_CATEGORY`.
40+
- **Image generation**`flux`, `gemini-image-preview`, and other image-output models work out of the box: returned `data:` URLs are uploaded to OWUI storage and embedded inline as `![Generated image](/api/v1/files/.../content)` so the chat client renders them natively.
41+
- **Video generation**`google/veo-3.1*`, `kwaivgi/kling*`, `openai/sora*`, `bytedance/seedance*`, `minimax/hailuo*`, `alibaba/wan*`, `x-ai/grok-imagine-video` are routed to OpenRouter's asynchronous `/api/v1/videos` endpoint (auto-polling, configurable `VIDEO_POLL_INTERVAL` / `VIDEO_GENERATION_TIMEOUT`), then re-hosted as OWUI files and embedded inline.
42+
- **Audio generation**`google/lyria-3-*-preview` (music) and `openai/gpt-audio*` (speech, auto pcm16 → WAV wrap for streaming) inject the required `modalities=["text","audio"]` + `audio={format,voice}` payload automatically, capture the base64 chunks, decode, upload, and embed as inline `<audio controls>`.
43+
- **SSRF-guarded media downloads** — polling URLs and signed download URLs are restricted to `openrouter.ai`; downloads are byte-capped (100 MiB video / 50 MiB audio) and MIME-whitelisted post-fetch.
3944
- **Web search plugin** — attach OpenRouter's `web` plugin to any model with domain allow/deny lists, custom search prompt, and result-count limits.
4045
- **Variant routing** — surface virtual `:nitro`/`:exacto`/`:thinking`/`:online`/`:free`/`:extended` model entries that route to OpenRouter's specialized profiles.
4146
- **Service tier hint** — forward `flex` (cheaper/slower) or `priority` (faster) tiers to compatible providers.
@@ -91,7 +96,7 @@ All OpenRouter models will appear in the model selector immediately.
9196
git clone https://github.com/sena-labs/Open-WebUI-Pipe-OpenRouter.git
9297
cd Open-WebUI-Pipe-OpenRouter
9398
pip install -r requirements.txt
94-
python test_pipe.py # 727 tests — verify everything is green
99+
python test_pipe.py # 868 tests — verify everything is green
95100
```
96101

97102
## Usage
@@ -257,7 +262,7 @@ The pipe implements the **Manifold** pattern: one pipe entry point that surfaces
257262
Open-WebUI-Pipe-OpenRouter/
258263
├── openrouter_pipe.py # Main pipe source — install this in Open WebUI
259264
├── function.json # Open WebUI community manifest
260-
├── test_pipe.py # Unit test suite (727 tests)
265+
├── test_pipe.py # Unit test suite (868 tests)
261266
├── integration_test.py # Live API integration tests (44 assertions)
262267
├── TESTING.md # Manual pre-release checklist
263268
├── SECURITY.md # Security policy
@@ -287,7 +292,7 @@ It also removes `user` when sent as a dict (Open WebUI format) since OpenRouter
287292
## Development
288293

289294
```bash
290-
python test_pipe.py # Unit tests (727 tests)
295+
python test_pipe.py # Unit tests (868 tests)
291296
python integration_test.py # Live API tests (requires OPENROUTER_API_KEY)
292297
```
293298

@@ -383,14 +388,43 @@ A: `FALLBACK_MODELS` adds extra model IDs to the `models` array in the OpenRoute
383388
primary model fails, OpenRouter automatically tries the next one. Non-streaming responses include
384389
a "Responded by: model-id" attribution when a fallback handled the request.
385390

386-
**Q: I selected a TTS / embeddings / image-generation model and got an error — why?**
387-
388-
A: The pipe routes every request through OpenRouter's `/chat/completions` endpoint. Models that
389-
only expose a non-chat endpoint (e.g. pure TTS models served via `/audio/speech`) return an
390-
"endpoint not supported" error from OpenRouter. The pipe surfaces that error verbatim. Chat
391-
completion models that *output* audio or images (e.g. `openai/gpt-audio`) work normally — their
392-
audio transcript and generated images are rendered inline. To hide non-chat models from the
393-
selector entirely, set `OUTPUT_MODALITIES = text`.
391+
**Q: How do image / video / audio generation models work in the pipe?**
392+
393+
A: The pipe inspects each model's `architecture.output_modalities` (from OpenRouter's `/models`
394+
catalog) and routes accordingly:
395+
396+
- **Image-output models** (`flux`, `gemini-image-preview`, ...) — served via the standard
397+
`/chat/completions` endpoint. The returned base64 `data:` URLs in `choices[0].message.images`
398+
are uploaded through Open WebUI's file-upload helper and the message content is rewritten to
399+
`![Generated image](/api/v1/files/<id>/content)` so the OWUI chat renders them inline.
400+
- **Video-output models** (`veo`, `kling`, `sora`, `seedance`, `hailuo`, `wan`, `grok-imagine`)
401+
— NOT served by `/chat/completions` (that endpoint 500s for them). The pipe submits to
402+
`POST /api/v1/videos`, polls the returned `polling_url` every `VIDEO_POLL_INTERVAL`
403+
(default 5 s) up to `VIDEO_GENERATION_TIMEOUT` (default 600 s), downloads the MP4 once the
404+
job is `completed`, re-hosts it through OWUI, and embeds as a native `<video controls>`
405+
element. Status updates appear in the OWUI status shimmer during polling.
406+
- **Audio-output models** (`google/lyria-3-clip-preview`, `google/lyria-3-pro-preview`,
407+
`openai/gpt-audio`, `openai/gpt-audio-mini`) — served by `/chat/completions` but only
408+
emit audio when the request includes `modalities=["text","audio"]` + an `audio={format,voice}`
409+
object + `stream=true`. The pipe injects all three automatically. For OpenAI's `gpt-audio*`
410+
the format is forced to `pcm16` (the upstream's only supported format with `stream=true`)
411+
and the raw PCM is wrapped in a WAV container before upload so the browser can play it.
412+
For Lyria the default is mp3.
413+
414+
Bytes are downloaded with hard caps (100 MiB video / 50 MiB audio), the post-fetch MIME is
415+
checked against a per-modality whitelist, and the `polling_url` + `unsigned_urls[0]` are
416+
restricted to `openrouter.ai` (or `*.openrouter.ai`) so a compromised upstream cannot redirect
417+
the Authorization bearer to an attacker-controlled host.
418+
419+
To hide non-chat models from the selector entirely, set `OUTPUT_MODALITIES = text`.
420+
421+
**Q: I selected a pure-embeddings or pure-TTS model and got an error — why?**
422+
423+
A: The pipe routes those through `/chat/completions` (the same endpoint that backs every other
424+
chat). Models that only expose a non-chat endpoint (e.g. pure TTS models served via
425+
`/audio/speech`) return an "endpoint not supported" error from OpenRouter; the pipe surfaces
426+
that error verbatim. Use `OUTPUT_MODALITIES = text,image,audio,video` (or `all`) in the valves
427+
to control which modalities appear in the selector.
394428

395429
## License
396430

TESTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ Must exit with `All tests passed! ✓` and `✗ Failed: 0`. If any test fails, *
295295

296296
## Quick pre-release checklist
297297

298-
- [ ] `python test_pipe.py`727 passed, 0 failed
298+
- [ ] `python test_pipe.py`868 passed, 0 failed
299299
- [ ] `python integration_test.py` → 44/44
300300
- [ ] Empty API key → clear error message in model selector
301301
- [ ] Valid API key → 400+ models with provider icons

0 commit comments

Comments
 (0)