Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/fly-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
concurrency: deploy-group # optional: ensure only one action runs at a time
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Tests

# The deploy workflow (fly-deploy.yml) has NO test gate: it deploys every push
# to main. This workflow is the gate. Keep it required on main so a red suite
# cannot reach production.

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Install uv
# Exact pin, not a floating major: setup-uv's newest release is v9.0.0
# but its floating major tags stop at v7, so `@v9` does not resolve.
uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true

# Interpreter comes from pyproject's requires-python, never a hardcoded
# version that can drift away from the project.
- name: Set up Python
run: uv python install

- name: Install dependencies
run: uv sync --frozen

- name: Run tests
env:
# A fake key is correct: the suite asserts on wiring and accepts a
# Deepgram 401. It must never need a real credential to run, and it
# must never need APP_ACCESS_TOKEN either, since an unset token is the
# "everyone is privileged" path the tests rely on.
DEEPGRAM_API_KEY: ci-test-key
run: uv run pytest tests/ -q
141 changes: 58 additions & 83 deletions .github/workflows/update-deepgram-sdk.yaml
Original file line number Diff line number Diff line change
@@ -1,105 +1,80 @@
name: Update Deepgram SDK

# Opens a PR when a newer deepgram-sdk is published.
#
# The pin lives in exactly ONE place: [project] dependencies in pyproject.toml,
# resolved through uv.lock. DO NOT reintroduce requirements.txt here. The
# previous version of this workflow edited requirements.txt and then ran
# `pip install -r requirements.txt`, which pulled the dead v1 Flask stack
# (Flask 3.0.0, deepgram-sdk v3.10.1) on Python 3.10 against a project that
# requires >=3.12. It could only no-op or open a broken PR, and it left roughly
# a dozen abandoned update-deepgram-sdk-v* branches on the remote.

on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at midnight
- cron: "0 0 * * 1" # Every Monday at midnight UTC
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
check-update:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v3
- uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v4
- name: Install uv
# Exact pin, not a floating major: setup-uv's newest release is v9.0.0
# but its floating major tags stop at v7, so `@v9` does not resolve.
uses: astral-sh/setup-uv@v9.0.0
with:
python-version: "3.10"

- name: Install required tools
run: sudo apt-get install -y jq curl
enable-cache: true

- name: Get current Deepgram Python SDK version
id: get-deepgram-version
run: |
DEEPGRAM_VERSION=$(curl -s https://api.github.com/repos/deepgram/deepgram-python-sdk/releases/latest | jq -r '.tag_name')
echo "version=$DEEPGRAM_VERSION" >> $GITHUB_ENV
# Read the interpreter from pyproject's requires-python rather than
# hardcoding a version that can drift away from the project.
- name: Set up Python
run: uv python install

- name: Check installed Deepgram SDK version
id: check-installed-version
- name: Resolve current and latest deepgram-sdk
id: versions
run: |
INSTALLED_VERSION=$(pip show deepgram-sdk | grep Version | cut -d' ' -f2)
echo "installed_version=$INSTALLED_VERSION" >> $GITHUB_ENV
set -euo pipefail
CURRENT=$(uv run python -c "import importlib.metadata as m; print(m.version('deepgram-sdk'))")
LATEST=$(curl -fsSL https://pypi.org/pypi/deepgram-sdk/json | jq -r '.info.version')
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
echo "Current: $CURRENT Latest: $LATEST"

- name: Config git
env:
GITHUB_TOKEN: ${{ secrets.GH_RELEASE_ACCESS_TOKEN }}
shell: bash
- name: Bump the pin and lockfile
if: steps.versions.outputs.current != steps.versions.outputs.latest
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global init.defaultBranch main
git config --global pull.rebase true
git config --global url."https://git:$GITHUB_TOKEN@github.com".insteadOf "https://github.com"
set -euo pipefail
uv add "deepgram-sdk==${{ steps.versions.outputs.latest }}"
uv lock

- name: Compare versions and update if necessary
- name: Run tests against the new version
if: steps.versions.outputs.current != steps.versions.outputs.latest
env:
GITHUB_TOKEN: ${{ secrets.GH_RELEASE_ACCESS_TOKEN }}
run: |
LATEST_VERSION=${{ env.version }}
INSTALLED_VERSION=${{ env.installed_version }}
# A fake key is correct here: the suite asserts on wiring and accepts
# a Deepgram 401. It must never need a real credential to run.
DEEPGRAM_API_KEY: ci-test-key
run: uv run pytest tests/ -q

if [ "$LATEST_VERSION" != "$INSTALLED_VERSION" ]; then
echo "Updating Deepgram SDK from $INSTALLED_VERSION to $LATEST_VERSION"
pip install deepgram-sdk==$LATEST_VERSION

# Update requirements.txt
sed -i "s/deepgram-sdk==.*/deepgram-sdk==$LATEST_VERSION/" requirements.txt
git add requirements.txt

# Create a new branch and commit changes
BRANCH_NAME="update-deepgram-sdk-$LATEST_VERSION"
git checkout -b "$BRANCH_NAME"
git commit -m "chore: update Deepgram SDK to $LATEST_VERSION"

# Push the new branch and create a PR
git push origin "$BRANCH_NAME"
gh pr create --title "chore: update Deepgram SDK to $LATEST_VERSION" --body "This PR updates the Deepgram SDK to version $LATEST_VERSION." --base "main" --head "$BRANCH_NAME"
else
echo "Deepgram SDK is up to date"
fi

- name: Install dependencies
run: pip install -r requirements.txt

- name: Install dev dependencies
run: pip install -r requirements-dev.txt

- name: Run tests
env:
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
run: pytest tests/

- name: Notify on failure
if: failure()
uses: slackapi/slack-github-action@v1.23.0
- name: Open a PR
if: steps.versions.outputs.current != steps.versions.outputs.latest
uses: peter-evans/create-pull-request@v8
with:
payload: |
{
"text": "The tests have FAILED for ${{ github.repository }}."
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
branch: chore/deepgram-sdk-${{ steps.versions.outputs.latest }}
delete-branch: true
title: "chore: update deepgram-sdk to ${{ steps.versions.outputs.latest }}"
commit-message: "chore: update deepgram-sdk to ${{ steps.versions.outputs.latest }}"
body: |
Bumps `deepgram-sdk` from `${{ steps.versions.outputs.current }}`
to `${{ steps.versions.outputs.latest }}` in `pyproject.toml` and
`uv.lock`.

- name: Notify on success
if: success()
uses: slackapi/slack-github-action@v1.23.0
with:
payload: |
{
"text": "The tests have passed for ${{ github.repository }}."
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
The test suite passed against the new version in CI. A major bump
can still change the streaming API shape, so check
`.planning/research/STACK.md` before merging one.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ __pycache__/
.python-version
.env
outputs/*

# Generated evidence from scripts/. Regenerable; keep deliberately, not by accident.
scripts/*.csv

# Claude Code local overrides
.claude/settings.local.json
89 changes: 87 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,84 @@ Copy `sample.env` to `.env`:

```env
DEEPGRAM_API_KEY=your_key_here
ELEVENLABS_API_KEY= # optional, only for the ElevenLabs TTS provider
```

---
### Security and abuse limits

This app holds a Deepgram key server-side and calls Deepgram on behalf of
whoever hits it, so these bound what a caller can do:

| Variable | Default | Purpose |
|----------|---------|---------|
| `APP_ACCESS_TOKEN` | *(unset)* | Shared secret that LIFTS the anonymous limits. Unset means every caller is privileged, which is what local dev wants. |
| `REQUIRE_AUTH` | *(unset)* | When true, the app refuses to boot without `APP_ACCESS_TOKEN`, so a deploy cannot silently come up with its limits disabled. Set to `true` in `fly.toml`. |
| `ANON_ACCESS` | `true` | Set false to require a token outright. Leaving it true is the point: the demo has to work for a visitor. |
| `ANON_RATE_LIMIT` / `ANON_RATE_WINDOW_S` | `15` / `300` | Per-IP request budget. |
| `ANON_GLOBAL_LIMIT` / `ANON_GLOBAL_WINDOW_S` | `300` / `3600` | Budget across ALL anonymous traffic. This is the control that actually caps the bill. |
| `ANON_MAX_CONCURRENT_STREAMS` | `3` | Simultaneous untokened streams. |
| `ANON_MAX_STREAM_SECONDS` | `120` | Wall-clock cap on an untokened stream. |
| `ANON_MAX_TTS_CHARS` / `ANON_MAX_UPLOAD_BYTES` | `400` / `10MB` | Tighter per-request caps for the anonymous tier. |
| `MAX_TTS_CHARS` / `MAX_UPLOAD_BYTES` | `2000` / `100MB` | Per-request caps for the privileged tier. |
### Access is a budget, not a wall

This app is both a capability demo and a diagnostic tool, so an anonymous
visitor gets a **working** app, mic streaming included. A dead UI demos nothing.
The token raises the limits rather than unlocking the door.

| | Anonymous | With a token |
|---|---|---|
| Load the UI | yes | yes |
| Mic / file / batch / TTS | yes | yes |
| Requests | 15 per IP per 5 min, and 300/hour across all anonymous traffic | unlimited |
| Concurrent streams | 3 | unlimited |
| Stream length | 120s | unlimited |
| TTS text | 400 chars | 2000 chars |
| Upload | 10MB | 100MB |
| Audio by URL | must report a `Content-Length` within the upload cap | any |

**The global hourly ceiling is the load-bearing control, not the per-IP limit.**
IPs are cheap, so per-IP only stops one person hammering; the global cap is what
makes a distributed attempt pointless. Do not remove it on the grounds that
per-IP covers it.

**The SocketIO stream is metered exactly like the HTTP API.** Mic and file
streaming spend the Deepgram key over that socket. Do not exempt it because
"only our own page opens it": the browser is attacker-controlled, so there is no
way to authenticate the page as distinct from a user, and anyone who loads the
public page can read its network calls and replay them.

**An anonymous caller may not hand Deepgram a URL of unknown length.** Uploads
are capped as they stream, but one allowed request pointing at a ten-hour
recording is unbounded spend the rate limit cannot see, so `/transcribe` HEADs
the URL first and refuses a missing or oversized `Content-Length`.

Three ways to send the token, in precedence order:

```bash
curl -H "X-App-Token: $APP_ACCESS_TOKEN" ... # canonical
curl -H "Authorization: Bearer $APP_ACCESS_TOKEN" ...
curl "https://…/files/clip.wav?token=$APP_ACCESS_TOKEN" # for <audio src>, which cannot send headers
```

Share the app as `https://your-app.fly.dev/?token=YOUR_TOKEN`. The frontend
reads the token, stores it in `sessionStorage`, strips it back out of the visible
URL so it does not leak into a screenshot, and attaches it to every call
including the socket handshake. Anonymous visitors see a banner explaining the
demo budget, and a hit limit is reported in place rather than looking broken.
Scripts in `scripts/` read `APP_ACCESS_TOKEN` from the environment.

```bash
fly secrets set APP_ACCESS_TOKEN="$(openssl rand -hex 24)"
```

**Rate-limit state is in-process, which is correct here:** `fly.toml` pins the
app to a single machine with a single worker because python-socketio keeps
session state in memory. Scaling past one machine needs an external store for
these counters as well as for the socket sessions.

**Still open:** `cors_allowed_origins` is `"*"`. Use a scoped `usage:write`
Deepgram key rather than an org-wide one, and set a spend ceiling with alerting.

## Supported Redact Values

Expand Down Expand Up @@ -143,7 +218,7 @@ All values below are verified to work with the Deepgram streaming API:
uv run pytest tests/ -v
```

30 tests, 1 skipped. Tests use a real `UvicornTestServer` + `socketio.AsyncClient` — no mocking of the SocketIO layer.
86 tests, 1 skipped. Tests use a real `UvicornTestServer` + `socketio.AsyncClient` — no mocking of the SocketIO layer.

<!-- TODO: add screenshot of batch mode -->
<!-- ![Deepgram STT Explorer — Batch Mode](docs/images/stt-batch.png) -->
Expand All @@ -161,6 +236,8 @@ Non-obvious things discovered during the Flask/gevent → FastAPI async migratio
- **Audio `timeslice` must be 250ms** — 1000ms chunks cause the last word before Stop to be dropped
- **`stream_started` must emit immediately on WS connect**, not after `Metadata` — Metadata timing is non-deterministic and can block the frontend for 10+ seconds
- **Deepgram boolean params must be lowercase strings** (`"true"`/`"false"`), not Python bools
- **Never join a caller-supplied filename onto a directory** — Python's `Path` join *replaces* the base when the right side is absolute, so `TEMP_DIR / "/etc/passwd"` is `/etc/passwd`. Take `Path(name).name` and assert the resolved parent.
- **Never interpolate a caller-supplied host into a URL that carries your API key** — it forwards the credential to whatever host they named.

---

Expand All @@ -172,6 +249,14 @@ fly secrets set DEEPGRAM_API_KEY=your_key_here
fly deploy
```

Pushing to `main` also deploys, via `.github/workflows/fly-deploy.yml`. That
workflow itself has no test gate; `.github/workflows/test.yml` is the gate and
runs the suite on every PR and every push to `main`. Keep it required on `main`
in branch protection, or a red suite can still reach production.

**Check whether the running app is ahead of `main` before you push:** deploying
`main` while production carries newer code silently reverts it.

The `fly.toml` and `Dockerfile` are already configured for uvicorn on port 8080.

---
Expand Down
Loading