Skip to content

Commit c5fce57

Browse files
authored
Add authed STT proxy route POST /v1/stt/transcribe (#8854 step 1) (#9011)
## What Adds `POST /v1/stt/transcribe` — an authenticated backend proxy in front of the parakeet GPU service's `/v2/transcribe`. This is step 1 of the parakeet cleanup plan laid out in #8854 ("Add new authed backend proxy route for mobile app"). ## Why When a user selects the "Omi Parakeet" STT provider, the mobile app currently posts audio buffers directly to `parakeet.omiapi.com/v1/transcribe` with no authentication of any kind (`app/lib/models/stt_provider.dart:533` builds the request with no Authorization header, and the parakeet service has no auth guard of its own). That leaves a GPU transcription service effectively open to the public at that path. This route puts the standard Omi stack in front of it: - **Auth**: `get_current_user_uid` (Firebase ID token), same dependency as every other authed endpoint. - **Rate limiting**: new `stt:transcribe` policy (60/h per UID, mirrors `voice:transcribe`). - **Resource isolation**: dedicated `get_stt_proxy_client()` / `get_stt_proxy_semaphore()` (4 connections, keep-alive off) so bulk uploads can never starve the listen pipeline's internal STT pool (VAD, speaker embedding, speech profile). Requests fail fast with 503 if no upstream slot frees up within 30s, and the body is only buffered into RAM after a slot is held (max 4 resident bodies; waiters stay in the multipart disk spool). - **Abuse guards**: 200MB body cap (Content-Length + `file.size` pre-checks, plus a bounded read as defense-in-depth), and upstream filename sanitization — parakeet builds its temp path from the client filename, so path separators, dot-prefixed and overlong names are never forwarded. - **Error mapping**: parakeet's client-actionable 413 (audio too long) and 503 (model loading / overloaded) are forwarded with their detail; any other upstream failure maps to a generic 502 with nothing leaked. - Route gets a 350s path timeout (`HTTP_STT_TRANSCRIBE_TIMEOUT`) to cover the 300s upstream budget; the 120s POST default would kill long transcriptions. The response mirrors parakeet `/v2/transcribe` verbatim (`{"text", "segments", "detected_language"}`), so migrating the app is a URL + auth-header swap. ## Follow-ups (out of scope, per the #8854 sequencing) - Migrate the app's `omiParakeet` provider to this route — needs a dynamic auth header (Firebase tokens expire hourly; `SchemaBasedSttProvider` takes static headers at socket creation), so it's a separate app-side change. - Deprecate / remove the public `/v1/transcribe` exposure after the soak period. ## Testing - 28 unit tests (`backend/tests/unit/test_stt_router.py`, registered in `test.sh`): auth required, config guard, empty/oversized payloads (including a chunked body with no Content-Length and a direct-call test pinning the bounded read when `file.size` is unavailable), filename sanitization matrix, slot exhaustion → 503, response passthrough, `diarize` forwarding, upstream 413/503 forwarded (dict, non-dict JSON and HTML bodies), upstream 500 → 502 with no body leak, network error → 502. - End-to-end: ran the real router under uvicorn against a fake parakeet upstream and exercised it with `curl` + a real WAV — 401 without auth, 200 with exact JSON passthrough, `diarize=false` forwarded, multipart filename `../../etc/evil.wav` arriving upstream as `evil.wav`. - Full `backend/test.sh` run; `scan_async_blockers.py` and `scan_import_time_side_effects.py` clean. Refs #8854 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/BasedHardware/omi/pull/9011?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
2 parents af53c62 + 99daf21 commit c5fce57

8 files changed

Lines changed: 551 additions & 3 deletions

File tree

backend/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Serving STT provider/surface policy and canonical model order are owned exclusiv
141141
- **diarizer** (`diarizer/main.py`) — GPU. Speaker embeddings at `/v2/embedding`. Called by backend and pusher (`HOSTED_SPEAKER_EMBEDDING_API_URL`).
142142
- **vad** (`modal/main.py`) — GPU. `/v1/vad` and `/v1/speaker-identification`. Called by backend only.
143143
- **deepgram-self-hosted** — GPU STT deployment behind an explicit non-cloud endpoint. Hosted Deepgram serves the live streaming surface (`dg-nova-3` first, `DEEPGRAM_API_KEY`); it stays off batch. A deployment that sets `DEEPGRAM_SELF_HOSTED_ENABLED` must point at its own endpoint — never `api.deepgram.com`.
144-
- **parakeet** (`parakeet/`) — GPU STT service for streaming and pre-recorded transcription. Called by backend when `HOSTED_PARAKEET_API_URL` is set and Parakeet is selected.
144+
- **parakeet** (`parakeet/`) — GPU STT service for streaming and pre-recorded transcription. Called by backend when `HOSTED_PARAKEET_API_URL` is set and Parakeet is selected. Clients must not call it directly — they go through the authed backend proxy `POST /v1/stt/transcribe` (`routers/stt.py`).
145145
- **modulate** — Managed STT provider for configured languages. Called by backend when `MODULATE_API_KEY` is configured and Modulate is selected.
146146
- **nllb-translation** (`nllb_translation/`) — GPU translation service. Called by backend when `HOSTED_TRANSLATION_API_URL` is set and NLLB is selected.
147147
- **backend-sync** (`main.py`, same image as backend) — Cloud Run admission service for `/v2/sync-local-files`. The server classifies whole batches: recordings no more than six hours old enter `sync-jobs` (fresh), while older or untrusted batches enter `sync-backfill` and the scale-to-zero **backend-sync-backfill** worker. Fresh keeps its bounded inline fallback; backfill never falls into fresh/inline capacity. Backfill defaults to one in-flight job per UID, four processed speech hours per UID/day, 555 processed speech hours globally/day, a 30-day lookback, and four queue workers. Live fair-use reads only `realtime + sync_fresh`; `sync_backfill` is separately metered. A 45-day Firestore content ledger protects transcription and usage side effects across job expiry and re-upload. Audio playback merges (`/v1/sync/audio/*`) follow the same pattern via queue `audio-merge` building 30-day MP3 artifacts under `playback/` (`AUDIO_MERGE_DISPATCH_MODE`) — per-part files plus one dense per-conversation `conversation.mp3` whose spans manifest + audio_files fingerprint are stamped on the conversation doc (`conversation_audio`); a fingerprint mismatch after late chunks re-enqueues the build. In production, account deletion requires `ACCOUNT_DELETION_DISPATCH_MODE=cloud_tasks` and complete Cloud Tasks bindings to enqueue opaque job IDs to queue `account-deletion`, which posts `/v1/users/account-deletion-wipes/run`; startup rejects inline or incomplete configuration, reconciliation only re-dispatches tasks so the OIDC handler is the sole wipe executor, and the post-deploy queue-drain window accepts the former sync OIDC audience only for legacy UID payloads. API success is returned only after the deletion marker is persisted and the wipe task is durably enqueued.
@@ -249,7 +249,7 @@ black --line-length 120 --skip-string-normalization <files>
249249
Never block the event loop — it freezes health checks, HPA scaling, and all concurrent connections.
250250

251251
- **Lane 1 — Async HTTP** (`utils/http_client.py`): Shared `httpx.AsyncClient` pools with semaphore-bounded concurrency. Never `requests.*` or sync `httpx.*` in async code.
252-
- Clients: `get_webhook_client()`, `get_maps_client()`, `get_auth_client()`, `get_stt_client()`
252+
- Clients: `get_webhook_client()`, `get_maps_client()`, `get_auth_client()`, `get_stt_client()`, `get_stt_proxy_client()`, `get_tts_client()`, `get_web_fetch_client()`
253253
- Semaphores: always wrap calls — `async with get_webhook_semaphore(): await client.post(...)`
254254
- Circuit breakers: `get_webhook_circuit_breaker(url)` for external targets — call `cb.record_success()`/`cb.record_failure()`
255255
- Lifecycle: lazy singletons, closed at shutdown via `close_all_clients()`

backend/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
desktop_screen_crisp,
8181
desktop_tts_updates,
8282
scores,
83+
stt,
8384
tts,
8485
memory_admin,
8586
memory_product,
@@ -210,6 +211,7 @@
210211
app.include_router(chat_sessions.router)
211212
app.include_router(chat_generation.router)
212213
app.include_router(scores.router)
214+
app.include_router(stt.router)
213215
app.include_router(tts.router)
214216
app.include_router(memory_admin.router)
215217
app.include_router(memory_product.router)
@@ -239,6 +241,10 @@
239241
"/v2/audio-merge-jobs/run": os.environ.get('HTTP_AUDIO_MERGE_RUN_TIMEOUT', 600),
240242
"/v1/users/account-deletion-wipes/run": os.environ.get('HTTP_ACCOUNT_DELETION_WIPE_RUN_TIMEOUT', 1500),
241243
"/v1/conversation-finalization-jobs/run": os.environ.get('HTTP_LISTEN_FINALIZATION_RUN_TIMEOUT', 1500),
244+
# STT proxy: 30s slot wait + 300s parakeet client budget (get_stt_proxy_client)
245+
# + headroom for auth and the multipart spool read; the default POST timeout
246+
# would cut long files off mid-transcription.
247+
"/v1/stt/transcribe": os.environ.get('HTTP_STT_TRANSCRIBE_TIMEOUT', 350),
242248
}
243249

244250
app.add_middleware(TimeoutMiddleware, methods_timeout=methods_timeout, paths_timeout=paths_timeout)

backend/route_policy_manifest.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,29 @@ routes:
7777
method: POST
7878
path: /v1/tts/synthesize
7979
policy: *desktop_migration_policy
80+
- route_type: http
81+
method: POST
82+
path: /v1/stt/transcribe
83+
policy:
84+
review_status: reviewed
85+
auth:
86+
mechanisms:
87+
- firebase_id_token
88+
placement: dependency
89+
scopes: []
90+
byok: not_applicable
91+
rate_limit:
92+
policy_name: stt:transcribe
93+
key_subject: uid
94+
enforcement: fail_closed
95+
placement: wrapper
96+
timeout_class: unknown
97+
surface: first_party_app
98+
visibility: first_party
99+
data_domain: conversations
100+
deprecation:
101+
state: active
102+
owner: backend
80103
- route_type: http
81104
method: POST
82105
path: /v1/webhooks/sentry

backend/routers/stt.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""STT proxy route — authed proxy in front of the parakeet transcription service.
2+
3+
Step 1 of the parakeet cleanup plan (issue #8854): clients must not call the
4+
parakeet service directly (it has no auth of its own — only the internal LB
5+
shields it). This route puts the standard Omi auth guard plus per-UID rate
6+
limiting in front of parakeet's `/v2/transcribe`, so the mobile app's
7+
"Omi Parakeet" provider can migrate off the direct `parakeet.omiapi.com` URL.
8+
9+
The response mirrors parakeet `/v2/transcribe` verbatim:
10+
{"text": str, "segments": [{"start", "end", "text", "speaker"}], "detected_language": str}
11+
"""
12+
13+
import asyncio
14+
import logging
15+
import os
16+
import re
17+
18+
import httpx
19+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
20+
21+
from utils.http_client import get_stt_proxy_client, get_stt_proxy_semaphore
22+
from utils.log_sanitizer import sanitize
23+
from utils.other import endpoints as auth
24+
25+
logger = logging.getLogger(__name__)
26+
27+
router = APIRouter()
28+
29+
# Mirrors _MAX_PCM_BODY_BYTES in routers/chat.py. Parakeet enforces its own
30+
# duration cap (PARAKEET_MAX_FILE_DURATION); this only bounds backend memory.
31+
_MAX_UPLOAD_BYTES = 200_000_000
32+
33+
# How long a request may wait for an upstream slot before failing fast with
34+
# 503. Waiters hold no RAM (the body stays in starlette's disk spool until a
35+
# slot is acquired), so this only bounds client-perceived latency.
36+
_UPSTREAM_WAIT_SECS = 30.0
37+
38+
_SAFE_FILENAME_RE = re.compile(r'[^A-Za-z0-9._-]')
39+
_MAX_FILENAME_LEN = 64
40+
_MAX_EXTENSION_LEN = 8
41+
42+
43+
def _safe_upstream_filename(filename) -> str:
44+
"""Parakeet builds its temp file path from the client-supplied filename —
45+
never forward path separators, dot-prefixed names, or overlong names.
46+
Truncation keeps a short extension so upstream decoders that sniff by
47+
suffix still see it.
48+
"""
49+
base = os.path.basename(filename or '')
50+
base = _SAFE_FILENAME_RE.sub('_', base).lstrip('.')
51+
if len(base) > _MAX_FILENAME_LEN:
52+
stem, dot, ext = base.rpartition('.')
53+
if dot and 0 < len(ext) <= _MAX_EXTENSION_LEN:
54+
base = stem[: _MAX_FILENAME_LEN - len(ext) - 1] + '.' + ext
55+
else:
56+
base = base[:_MAX_FILENAME_LEN]
57+
return base or 'audio.wav'
58+
59+
60+
@router.post('/v1/stt/transcribe', tags=['stt'])
61+
async def stt_transcribe(
62+
request: Request,
63+
file: UploadFile = File(...),
64+
diarize: bool = Form(True),
65+
uid: str = Depends(auth.with_rate_limit(auth.get_current_user_uid, "stt:transcribe")),
66+
):
67+
"""Transcribe an uploaded audio file via parakeet. Per-user rate limited."""
68+
api_url = os.getenv('HOSTED_PARAKEET_API_URL')
69+
if not api_url:
70+
logger.error('stt_transcribe: HOSTED_PARAKEET_API_URL not configured')
71+
raise HTTPException(status_code=503, detail='Transcription service not configured')
72+
73+
# Starlette has already spooled the multipart body to disk by this point;
74+
# these checks bound backend RAM, not the upload itself. file.size is the
75+
# primary size enforcement — starlette populates it for every multipart
76+
# part, chunked uploads included. The Content-Length check just rejects a
77+
# step earlier when the header is present.
78+
content_length = request.headers.get('content-length')
79+
if content_length and content_length.isdigit() and int(content_length) > _MAX_UPLOAD_BYTES:
80+
raise HTTPException(status_code=413, detail=f'Body too large (max {_MAX_UPLOAD_BYTES} bytes)')
81+
if file.size is not None and file.size > _MAX_UPLOAD_BYTES:
82+
raise HTTPException(status_code=413, detail=f'Body too large (max {_MAX_UPLOAD_BYTES} bytes)')
83+
84+
upstream_url = api_url.rstrip('/') + '/v2/transcribe'
85+
client = get_stt_proxy_client()
86+
semaphore = get_stt_proxy_semaphore()
87+
88+
try:
89+
await asyncio.wait_for(semaphore.acquire(), timeout=_UPSTREAM_WAIT_SECS)
90+
except asyncio.TimeoutError:
91+
logger.warning(f'stt_transcribe: no upstream slot within {_UPSTREAM_WAIT_SECS}s uid={uid}')
92+
raise HTTPException(status_code=503, detail='Transcription service busy — try again shortly')
93+
94+
try:
95+
# Buffer only after holding a slot, so at most 4 request bodies are
96+
# resident in RAM at once — waiters keep theirs in the disk spool.
97+
# The bounded read is defense-in-depth should file.size be absent.
98+
audio_bytes = await file.read(_MAX_UPLOAD_BYTES + 1)
99+
if not audio_bytes:
100+
raise HTTPException(status_code=400, detail='No audio data provided')
101+
if len(audio_bytes) > _MAX_UPLOAD_BYTES:
102+
del audio_bytes
103+
raise HTTPException(status_code=413, detail=f'Body too large (max {_MAX_UPLOAD_BYTES} bytes)')
104+
105+
files = {'file': (_safe_upstream_filename(file.filename), audio_bytes, file.content_type or 'audio/wav')}
106+
data = {'diarize': 'true' if diarize else 'false'}
107+
try:
108+
resp = await client.post(upstream_url, files=files, data=data)
109+
except httpx.HTTPError as e:
110+
logger.error(f'stt_transcribe: parakeet request failed uid={uid}: {sanitize(str(e))}')
111+
raise HTTPException(status_code=502, detail='Transcription upstream unavailable')
112+
finally:
113+
del files
114+
del audio_bytes
115+
finally:
116+
semaphore.release()
117+
118+
if resp.status_code >= 400:
119+
# 413 (audio too long) and 503 (model loading / overloaded) carry
120+
# client-actionable details — forward them as-is. Anything else is an
121+
# internal upstream failure the client can't act on.
122+
if resp.status_code in (413, 503):
123+
detail = 'Transcription failed upstream'
124+
try:
125+
payload = resp.json()
126+
except ValueError:
127+
payload = None
128+
if isinstance(payload, dict) and payload.get('detail'):
129+
detail = str(payload['detail'])
130+
logger.warning(f'stt_transcribe: parakeet returned {resp.status_code} uid={uid}: {sanitize(detail)}')
131+
raise HTTPException(status_code=resp.status_code, detail=detail)
132+
logger.error(f'stt_transcribe: parakeet returned {resp.status_code} uid={uid}: {sanitize(resp.text[:200])}')
133+
raise HTTPException(status_code=502, detail='Transcription failed upstream')
134+
135+
try:
136+
return resp.json()
137+
except ValueError:
138+
logger.error(f'stt_transcribe: parakeet returned non-JSON success body uid={uid}')
139+
raise HTTPException(status_code=502, detail='Transcription upstream returned an invalid response')

0 commit comments

Comments
 (0)