From afb1ce9570cf5d41bfb06c000256c63fc2013144 Mon Sep 17 00:00:00 2001 From: Yi Ding Date: Sat, 8 Aug 2026 22:01:17 -0700 Subject: [PATCH 1/4] examples: fix setup and signaling inconsistencies --- docs/browser-playground.md | 5 +++-- docs/deployment/production-servers.md | 9 ++++++--- .../13-swap-providers-and-transports/README.md | 6 +++--- examples/README.md | 4 ++-- examples/custom_transport.py | 5 ++--- examples/function_tools_langchain.py | 10 +++++----- examples/noise_reduction_backends.py | 6 +++++- examples/session_actions_langchain.py | 10 +++++----- examples/vad_backends.py | 3 ++- examples/voice_app_twilio.py | 3 ++- examples/webrtc_static/webrtc_client.html | 15 +++++++++++++-- examples/webrtc_static/webrtc_observability.html | 9 ++++++++- tests/examples/test_deploy_and_browser_docs.py | 13 +++++++++++++ tests/examples/test_readme_matrix.py | 4 ++-- 14 files changed, 71 insertions(+), 31 deletions(-) diff --git a/docs/browser-playground.md b/docs/browser-playground.md index 871eb67a3..fc2d8a46b 100644 --- a/docs/browser-playground.md +++ b/docs/browser-playground.md @@ -93,8 +93,9 @@ Outbound (server → client): Audio flows over the Opus peer connection. Signaling is HTTP (`POST /offer`, `GET /config`, `POST /stats`, `GET /health`); when `WebRTCTransportConfig.auth_token` is set, `/config`, `/offer`, and `/stats` -require the token as `Authorization: Bearer ` or a `?token=` query -parameter. +require the token as `Authorization: Bearer `. A `?token=` query +parameter is accepted only when `allow_query_token=True`; it is disabled by +default because URLs leak more readily through logs and browser history. Session event messages arrive on a client-created data channel named `events`. diff --git a/docs/deployment/production-servers.md b/docs/deployment/production-servers.md index df27c0e87..0119fae7a 100644 --- a/docs/deployment/production-servers.md +++ b/docs/deployment/production-servers.md @@ -210,9 +210,12 @@ ICE server configuration for the bundled browser client, `POST /stats` accepts sanitized WebRTC stats snapshots, and `/health` reports `status`, `active_sessions`, and `max_sessions` for readiness checks. For public deployments, put the signaling server behind HTTPS so -`getUserMedia()` works, configure TURN, set `SIGNALING_AUTH_TOKEN` so `/offer` -and `/stats` require a bearer/query token, and tune `WEBRTC_MAX_SESSIONS` from -load-test data before raising the default cap. +`getUserMedia()` works, configure TURN, set `WEBRTC_SIGNALING_TOKEN` so +`/config`, `/offer`, and `/stats` require a bearer token, and tune +`WEBRTC_MAX_SESSIONS` from load-test data before raising the default cap. The +bundled client can read that token from its initial `?token=` URL and forwards +it in the `Authorization` header; direct query-token authentication remains +off unless `allow_query_token=True` is set explicitly. ### Flat routes vs. the `VoiceServer` `/webrtc/*` namespace diff --git a/docs/teaching/13-swap-providers-and-transports/README.md b/docs/teaching/13-swap-providers-and-transports/README.md index e154325e1..5a1b05c7d 100644 --- a/docs/teaching/13-swap-providers-and-transports/README.md +++ b/docs/teaching/13-swap-providers-and-transports/README.md @@ -240,10 +240,10 @@ Provider choice and transport choice optimise **different axes**: | Axis you care about | Choose this | |-----------------------------|-------------| | First-audio latency | Provider mix — compare `easycat latency` on repeated, matched turns | -| Jitter + packet loss | Transport — WebRTC preserves UDP end-to-end | -| Codec quality | Transport — Local / WebRTC (24 kHz) vs Twilio (μ-law 8 kHz) | +| Jitter + packet loss | Transport — inspect WebRTC's selected ICE path and client stats; TURN can relay media over UDP or TCP | +| Codec quality | Transport — Local uses 24 kHz PCM; WebRTC uses 48 kHz media frames with Opus around a 16 kHz pipeline; Twilio uses μ-law at 8 kHz on the wire | | Cost per turn | Provider mix — usually the dominant cost driver | -| Offline / on-device | Provider mix — (future: Cartesia / local models) | +| Offline / on-device | Provider mix — use a custom local/self-hosted provider; the bundled STT/TTS providers are hosted | | Reach a regular phone | Transport — Twilio only | Measure the production bundles with `easycat latency`; choose with diff --git a/examples/README.md b/examples/README.md index 6169f1011..9bbe48019 100644 --- a/examples/README.md +++ b/examples/README.md @@ -79,11 +79,11 @@ an additional third-party package to install in the same environment with | --- | --- | --- | --- | --- | | [function_tools_openai.py](function_tools_openai.py) | OpenAI Agents SDK function tools. | `uv run python examples/function_tools_openai.py` | `uv sync --extra quickstart --group dev` | `OPENAI_API_KEY` | | [function_tools_pydantic.py](function_tools_pydantic.py) | PydanticAI function tools. | `uv run python examples/function_tools_pydantic.py` | `uv sync --extra quickstart --extra pydantic-ai --group dev` | `OPENAI_API_KEY` | -| [function_tools_langchain.py](function_tools_langchain.py) | LangChain `AgentExecutor` tools. | `uv run python examples/function_tools_langchain.py` | `uv sync --extra quickstart --extra langchain --group dev`; `langchain<1`, `langchain-openai` | `OPENAI_API_KEY` | +| [function_tools_langchain.py](function_tools_langchain.py) | Legacy LangChain `AgentExecutor` tools. | `uv run python examples/function_tools_langchain.py` | `uv sync --extra quickstart --group dev`; `langchain<1`, `langchain-openai<1` | `OPENAI_API_KEY` | | [function_tools_langgraph.py](function_tools_langgraph.py) | LangGraph ReAct tools. | `uv run python examples/function_tools_langgraph.py` | `uv sync --extra quickstart --extra langgraph --group dev`; `langchain-openai` | `OPENAI_API_KEY` | | [session_actions_openai.py](session_actions_openai.py) | OpenAI tool that queues EasyCat session actions. | `uv run python examples/session_actions_openai.py` | `uv sync --extra quickstart --group dev` | `OPENAI_API_KEY` | | [session_actions_pydantic.py](session_actions_pydantic.py) | PydanticAI tool that queues session actions through deps. | `uv run python examples/session_actions_pydantic.py` | `uv sync --extra quickstart --extra pydantic-ai --group dev` | `OPENAI_API_KEY`, optional `PYDANTIC_AI_MODEL` | -| [session_actions_langchain.py](session_actions_langchain.py) | LangChain tool that can end the current session. | `uv run python examples/session_actions_langchain.py` | `uv sync --extra quickstart --extra langchain --group dev`; `langchain<1`, `langchain-openai` | `OPENAI_API_KEY` | +| [session_actions_langchain.py](session_actions_langchain.py) | Legacy LangChain tool that can end the current session. | `uv run python examples/session_actions_langchain.py` | `uv sync --extra quickstart --group dev`; `langchain<1`, `langchain-openai<1` | `OPENAI_API_KEY` | | [session_actions_langgraph.py](session_actions_langgraph.py) | LangGraph tool that can end the current session. | `uv run python examples/session_actions_langgraph.py` | `uv sync --extra quickstart --extra langgraph --group dev`; `langchain-openai` | `OPENAI_API_KEY` | | [agent_event_subscription.py](agent_event_subscription.py) | Subscribe to agent deltas and tool-call events from the session. | `uv run python examples/agent_event_subscription.py` | `uv sync --extra quickstart --group dev` | `OPENAI_API_KEY` | diff --git a/examples/custom_transport.py b/examples/custom_transport.py index 26cf2f18e..360e1e6b1 100644 --- a/examples/custom_transport.py +++ b/examples/custom_transport.py @@ -27,12 +27,11 @@ from collections.abc import AsyncIterator from easycat import AudioChunk, AudioFormat, EasyConfig, require_env, run -from easycat.providers import Transport from easycat.transports import LocalTransport class CountingTransport: - """Wraps any ``Transport`` and counts the audio bytes flowing each way. + """Wraps a ``LocalTransport`` and counts the audio bytes flowing each way. Implements the ``Transport`` Protocol structurally — no base class. The ``audio_format`` / ``clear_audio`` / ``default_echo_cancellation_enabled`` @@ -42,7 +41,7 @@ class CountingTransport: default_echo_cancellation_enabled = True - def __init__(self, inner: Transport) -> None: + def __init__(self, inner: LocalTransport) -> None: self._inner = inner self.bytes_in = 0 self.bytes_out = 0 diff --git a/examples/function_tools_langchain.py b/examples/function_tools_langchain.py index 2cca8d1bb..5d9f12baf 100644 --- a/examples/function_tools_langchain.py +++ b/examples/function_tools_langchain.py @@ -6,8 +6,8 @@ :class:`LangChainBridge`. For tools that drive the call (end, transfer, DTMF) see ``session_actions_langchain.py``. -Setup: export OPENAI_API_KEY=...; uv sync --extra quickstart --extra langchain --group dev - uv pip install "langchain<1" langchain-openai +Setup: export OPENAI_API_KEY=...; uv sync --extra quickstart --group dev + uv pip install "langchain<1" "langchain-openai<1" uv run easycat doctor uv run easycat doctor --env-file .env # if keys live in .env uv run easycat doctor --env-file .env --json # for parseable checks @@ -32,9 +32,9 @@ except ImportError as exc: raise SystemExit( "LangChain (<1.0) is required. For an app, run: " - "uv add 'easycat[quickstart,langchain]' 'langchain<1' langchain-openai. " - "In this repo, run: uv sync --extra quickstart --extra langchain --group dev; " - 'uv pip install "langchain<1" langchain-openai' + "uv add 'easycat[quickstart]' 'langchain<1' 'langchain-openai<1'. " + "In this repo, run: uv sync --extra quickstart --group dev; " + 'uv pip install "langchain<1" "langchain-openai<1"' ) from exc from easycat import EasyConfig, run diff --git a/examples/noise_reduction_backends.py b/examples/noise_reduction_backends.py index 5cda1c4e3..0ceadbf27 100644 --- a/examples/noise_reduction_backends.py +++ b/examples/noise_reduction_backends.py @@ -13,6 +13,8 @@ # Other choices: --backend krisp or --backend auto """ +from typing import Literal + try: from agents import Agent # type: ignore[import-untyped] except ImportError as exc: @@ -25,8 +27,10 @@ from easycat import EasyConfig, run from easycat.noise_reduction import NoiseReducerConfig, create_noise_reducer +NoiseReductionBackend = Literal["auto", "krisp", "rnnoise"] + -def main(backend: str) -> None: +def main(backend: NoiseReductionBackend) -> None: # Probe so you can see which class actually resolved before the session starts. # Close the probe afterwards: Krisp/RNNoise reducers hold native # resources (and Krisp may allow only one session at a time), so diff --git a/examples/session_actions_langchain.py b/examples/session_actions_langchain.py index 995e35935..b7988d0df 100644 --- a/examples/session_actions_langchain.py +++ b/examples/session_actions_langchain.py @@ -10,8 +10,8 @@ For telephony-specific actions (transfer, DTMF, SMS) see ``examples/twilio_app.py``. -Setup: export OPENAI_API_KEY=...; uv sync --extra quickstart --extra langchain --group dev - uv pip install "langchain<1" langchain-openai +Setup: export OPENAI_API_KEY=...; uv sync --extra quickstart --group dev + uv pip install "langchain<1" "langchain-openai<1" uv run easycat doctor uv run easycat doctor --env-file .env # if keys live in .env uv run easycat doctor --env-file .env --json # for parseable checks @@ -32,9 +32,9 @@ except ImportError as exc: raise SystemExit( "LangChain (<1.0) is required. For an app, run: " - "uv add 'easycat[quickstart,langchain]' 'langchain<1' langchain-openai. " - "In this repo, run: uv sync --extra quickstart --extra langchain --group dev; " - 'uv pip install "langchain<1" langchain-openai' + "uv add 'easycat[quickstart]' 'langchain<1' 'langchain-openai<1'. " + "In this repo, run: uv sync --extra quickstart --group dev; " + 'uv pip install "langchain<1" "langchain-openai<1"' ) from exc from easycat import EasyConfig, SessionActions, run diff --git a/examples/vad_backends.py b/examples/vad_backends.py index 8dceec164..fe5ebc1f7 100644 --- a/examples/vad_backends.py +++ b/examples/vad_backends.py @@ -28,6 +28,7 @@ from __future__ import annotations import argparse +from typing import Literal try: from agents import Agent # type: ignore[import-untyped] @@ -40,7 +41,7 @@ BACKENDS = ("auto", "silero", "funasr", "ten", "krisp") -def main(backend: str) -> None: +def main(backend: Literal["auto", "silero", "funasr", "ten", "krisp"]) -> None: if Agent is None: raise SystemExit( "openai-agents is required. For an app, run: " diff --git a/examples/voice_app_twilio.py b/examples/voice_app_twilio.py index ab1db5f7e..c29d6c277 100644 --- a/examples/voice_app_twilio.py +++ b/examples/voice_app_twilio.py @@ -36,6 +36,7 @@ ) from exc from easycat import EasyConfig, TelephonyConfig, VoiceApp, require_env +from easycat.transports import TwilioConnectionTransport def main() -> None: @@ -43,7 +44,7 @@ def main() -> None: stream_url = require_env("TWILIO_STREAM_URL") twilio_auth_token = require_env("TWILIO_AUTH_TOKEN") - def config_factory(transport: object) -> EasyConfig: + def config_factory(transport: TwilioConnectionTransport) -> EasyConfig: return EasyConfig.phone( transport=transport, agent=Agent(name="assistant", instructions="You are a helpful phone assistant."), diff --git a/examples/webrtc_static/webrtc_client.html b/examples/webrtc_static/webrtc_client.html index 05d3b6dc9..10153cea2 100644 --- a/examples/webrtc_static/webrtc_client.html +++ b/examples/webrtc_static/webrtc_client.html @@ -109,6 +109,17 @@

EasyCat WebRTC

var levelRAF = 0; var manualStop = false; +// The observability server prints tokenized URLs when signaling auth is +// enabled. Read that token and forward it as a bearer credential on every +// signaling request. +var authToken = new URLSearchParams(location.search).get("token") || ""; + +function authHeaders(extra) { + var headers = extra || {}; + if (authToken) headers["Authorization"] = "Bearer " + authToken; + return headers; +} + // ── UI ─────────────────────────────────────────────────────────── var startBtn = document.getElementById("startBtn"); var stopBtn = document.getElementById("stopBtn"); @@ -168,7 +179,7 @@

EasyCat WebRTC

// ── Fetch ICE config from server ───────────────────────────────── async function fetchIceConfig(baseUrl) { try { - var response = await fetch(baseUrl + "/config"); + var response = await fetch(baseUrl + "/config", { headers: authHeaders() }); if (response.ok) { return await response.json(); } @@ -200,7 +211,7 @@

EasyCat WebRTC

var response = await fetch(offerUrl + "/offer", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ sdp: pc.localDescription.sdp, type: pc.localDescription.type, diff --git a/examples/webrtc_static/webrtc_observability.html b/examples/webrtc_static/webrtc_observability.html index 462bc4fcc..e53cb2141 100644 --- a/examples/webrtc_static/webrtc_observability.html +++ b/examples/webrtc_static/webrtc_observability.html @@ -27,7 +27,7 @@

EasyCat WebRTC + Live Debugger

talk to the bot up top — watch the journal below
-
@@ -41,6 +41,13 @@

EasyCat WebRTC + Live Debugger

const params = new URLSearchParams(location.search); const override = params.get("debugger"); const debuggerPort = params.get("debugger_port") || "8090"; + const signalingToken = params.get("token") || ""; + + const webrtcParams = new URLSearchParams(); + if (signalingToken) webrtcParams.set("token", signalingToken); + const webrtcQuery = webrtcParams.toString(); + document.getElementById("webrtc-frame").src = + "/webrtc_client.html" + (webrtcQuery ? `?${webrtcQuery}` : ""); function isLoopbackHost(hostname) { return hostname === "localhost" diff --git a/tests/examples/test_deploy_and_browser_docs.py b/tests/examples/test_deploy_and_browser_docs.py index 5e94210d5..f38671285 100644 --- a/tests/examples/test_deploy_and_browser_docs.py +++ b/tests/examples/test_deploy_and_browser_docs.py @@ -649,6 +649,19 @@ def test_webrtc_observability_debugger_url_is_validated(): assert "const url = override ||" not in html +def test_webrtc_observability_client_forwards_signaling_token() -> None: + client = (REPO_ROOT / "examples/webrtc_static/webrtc_client.html").read_text() + observability = (REPO_ROOT / "examples/webrtc_static/webrtc_observability.html").read_text() + + assert 'new URLSearchParams(location.search).get("token")' in client + assert 'headers["Authorization"] = "Bearer " + authToken' in client + assert 'fetch(baseUrl + "/config", { headers: authHeaders() })' in client + assert 'headers: authHeaders({ "Content-Type": "application/json" })' in client + assert 'const signalingToken = params.get("token") || ""' in observability + assert 'webrtcParams.set("token", signalingToken)' in observability + assert 'document.getElementById("webrtc-frame").src =' in observability + + def test_webrtc_examples_default_signaling_to_loopback(): server = (REPO_ROOT / "examples" / "webrtc_server.py").read_text(encoding="utf-8") observability = (REPO_ROOT / "examples" / "webrtc_observability_server.py").read_text( diff --git a/tests/examples/test_readme_matrix.py b/tests/examples/test_readme_matrix.py index 5e27b08c6..fc6acb9db 100644 --- a/tests/examples/test_readme_matrix.py +++ b/tests/examples/test_readme_matrix.py @@ -264,13 +264,13 @@ def test_examples_readme_install_extras_cover_docstring_setup() -> None: def test_examples_readme_install_package_collector_reads_pip_and_package_snippets() -> None: install = ( - "`uv sync --extra quickstart --group dev`; `langchain<1`, `langchain-openai`, " + "`uv sync --extra quickstart --group dev`; `langchain<1`, `langchain-openai<1`, " "`--extra ten-vad`, or `uv pip install krisp_audio` for optional backends" ) assert _readme_install_packages(install) == { "krisp_audio", - "langchain-openai", + "langchain-openai<1", "langchain<1", } From f752764d6ea8958e27e83b63c719f15a8b15f47a Mon Sep 17 00:00:00 2001 From: Yi Ding Date: Sat, 8 Aug 2026 23:36:36 -0700 Subject: [PATCH 2/4] examples: close audited setup and signaling gaps --- .github/workflows/ci.yml | 24 +++++ CONTRIBUTING.md | 4 +- docs/browser-playground.md | 5 +- docs/deployment/production-servers.md | 21 +++-- .../README.md | 2 +- examples/README.md | 15 ++-- examples/custom_transport.py | 11 ++- examples/ec2_webrtc/deploy.sh | 13 ++- examples/noise_reduction_backends.py | 13 +-- examples/vad_backends.py | 10 +-- examples/voice_app_twilio.py | 7 +- examples/webrtc_observability_server.py | 11 ++- examples/webrtc_server.py | 21 +++-- examples/webrtc_static/webrtc_client.html | 25 +++++- .../webrtc_static/webrtc_observability.html | 24 +++-- justfile | 2 +- scripts/smoke_legacy_langchain_examples.py | 58 +++++++++++++ src/easycat/cli/serve.py | 8 +- src/easycat/server/_webrtc_handlers.py | 40 +++++++-- src/easycat/server/webrtc_routes.py | 5 +- src/easycat/transports/_webrtc_config.py | 6 +- .../transports/static/webrtc_client.html | 24 ++++- src/easycat/transports/webrtc.py | 16 +++- src/easycat/voice_app.py | 8 +- tests/cli/test_serve.py | 10 +-- .../examples/test_deploy_and_browser_docs.py | 51 +++++++++-- tests/examples/test_example_imports.py | 20 +++++ tests/examples/test_readme_matrix.py | 12 +++ tests/server/test_webrtc_routes.py | 87 ++++++++++++++----- .../test_provider_matrix_measurement.py | 13 +++ tests/test_dependency_policy.py | 19 +++- tests/transports/test_voice_app_modes.py | 10 +-- .../test_webrtc_auth_browser_playground.py | 16 +++- .../test_webrtc_lifecycle_server.py | 29 +++---- 34 files changed, 495 insertions(+), 145 deletions(-) create mode 100644 scripts/smoke_legacy_langchain_examples.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec9e58243..df3e61da9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,6 +173,30 @@ jobs: tests/tts/test_tts_openai.py tests/server/test_webrtc_routes.py + legacy-langchain-examples: + name: Legacy LangChain Examples + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.12.1" + enable-cache: true + python-version: "3.12" + - name: Create isolated legacy environment + run: uv venv --python 3.12 .venv-legacy-langchain + - name: Install documented legacy constraints + run: >- + uv pip install --python .venv-legacy-langchain/bin/python + -e ".[quickstart]" "langchain<1" "langchain-openai<1" + - name: Load legacy AgentExecutor examples + run: >- + .venv-legacy-langchain/bin/python + scripts/smoke_legacy_langchain_examples.py + coverage: name: Coverage + guard suite (diff-cover report-only) runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69b800fd7..4f28f95b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ raw equivalent in the table below. Install it with `uv tool install rust-just`, | Lint auto-fix | `just lint-fix` | `uv run ruff check --fix .` | | Format | `just fmt` | `uv run ruff format .` | | Format check | `just fmt-check` | `uv run ruff format --check .` | -| Type gate (mypy, whole package) | `just typecheck` | `uv run mypy src/easycat` | +| Type gate (mypy + legacy smoke script) | `just typecheck` | `uv run mypy src/easycat scripts/smoke_legacy_langchain_examples.py` | | Fast types (ty, advisory) | `just typecheck-fast` | `uvx ty check src/easycat` | | Coverage | `just cov` | `uv run pytest -n auto --dist load --cov --cov-report=term-missing -m "not integration_socket and not integration_live and not integration_external and not contract and not latency and not slow and not stress and not serial and not flaky and not guard"` | | Validate (quick) | `just validate-quick` | `uv run easycat validate quick` | @@ -63,7 +63,7 @@ raw equivalent in the table below. Install it with `uv tool install rust-just`, | Validate (release) | `just validate-release` | `uv run easycat validate release` | | Validate report | `just validate-report .easycat/validation/latest.json` | `uv run easycat validate report .easycat/validation/latest.json` | | Pre-commit hooks | `just pre-commit` | `uv run pre-commit run --all-files` | -| Pre-PR gauntlet | `just check` | `uv run pre-commit run --all-files && uv run mypy src/easycat && uv run pytest -n auto --dist loadscope -m "not integration_live and not integration_external and not serial" && uv run pytest -o faulthandler_timeout=0 -o timeout=0 -m "serial and not integration_live and not integration_external"` | +| Pre-PR gauntlet | `just check` | `uv run pre-commit run --all-files && uv run mypy src/easycat scripts/smoke_legacy_langchain_examples.py && uv run pytest -n auto --dist loadscope -m "not integration_live and not integration_external and not serial" && uv run pytest -o faulthandler_timeout=0 -o timeout=0 -m "serial and not integration_live and not integration_external"` | `just check` mirrors CI's core source-quality gates, but it is not a literal replay of the workflow: CI also covers the supported Python matrix, minimum diff --git a/docs/browser-playground.md b/docs/browser-playground.md index fc2d8a46b..ed44bd054 100644 --- a/docs/browser-playground.md +++ b/docs/browser-playground.md @@ -33,8 +33,9 @@ Useful options: defaults. - `--token` (or `EASYCAT_SERVE_TOKEN`) — shared secret required by the signaling endpoints. `easycat serve` refuses a non-loopback `--host` - without a token. The printed Open URL embeds it as `?token=...` and the - bundled client forwards it as an `Authorization: Bearer` header. + without a token. The printed Open URL embeds it in a `#token=...` fragment, + which is not sent in the HTTP request; the bundled client removes it from the + visible URL and forwards it as an `Authorization: Bearer` header. - `--agent-model` / `--instructions` — swap the playground agent's OpenAI Responses API model or its guidance. - `--manifest` / `--profile` — build a manifest-backed `VoiceServer` instead diff --git a/docs/deployment/production-servers.md b/docs/deployment/production-servers.md index 0119fae7a..0b7c7990e 100644 --- a/docs/deployment/production-servers.md +++ b/docs/deployment/production-servers.md @@ -213,9 +213,16 @@ For public deployments, put the signaling server behind HTTPS so `getUserMedia()` works, configure TURN, set `WEBRTC_SIGNALING_TOKEN` so `/config`, `/offer`, and `/stats` require a bearer token, and tune `WEBRTC_MAX_SESSIONS` from load-test data before raising the default cap. The -bundled client can read that token from its initial `?token=` URL and forwards -it in the `Authorization` header; direct query-token authentication remains -off unless `allow_query_token=True` is set explicitly. +bundled client can read that token from its initial `#token=` fragment, removes +it from the visible URL, and forwards it in the `Authorization` header. URL +fragments are not included in HTTP requests, while direct `?token=` query +authentication remains off unless `allow_query_token=True` is set explicitly. + +The server can use configured TURN credentials without returning TURN entries +from `/config`: the browser receives STUN-only config while the server peer can +still gather a relay candidate. Browser-side relay requires +`WEBRTC_EXPOSE_ICE_CREDENTIALS=1`; expose only short-lived TURN credentials (or +use this with a trusted demo), because every authorized client can read them. ### Flat routes vs. the `VoiceServer` `/webrtc/*` namespace @@ -235,9 +242,11 @@ implementation (`easycat.server.webrtc_routes.WebRTCRoutes`): The SAME bundled client HTML serves both. It resolves its route base from a `?webrtc=` query parameter (defaulting to `""` for the flat helper); -`VoiceServer`'s root redirect appends `?webrtc=/webrtc` (preserving any -`?token=`) so the served client targets the namespaced routes. A custom client -can target either surface by setting `?webrtc=` (or its own base) accordingly. +`VoiceServer`'s root redirect appends `?webrtc=/webrtc` so the served client +targets the namespaced routes. It drops any legacy `?token=` rather than copy a +secret into another HTTP request; bootstrap the bundled client with the +`#token=` fragment instead. A custom client can target either surface by +setting `?webrtc=` (or its own base) accordingly. ## WebTransport servers diff --git a/docs/teaching/13-swap-providers-and-transports/README.md b/docs/teaching/13-swap-providers-and-transports/README.md index 5a1b05c7d..87bef0173 100644 --- a/docs/teaching/13-swap-providers-and-transports/README.md +++ b/docs/teaching/13-swap-providers-and-transports/README.md @@ -290,7 +290,7 @@ numbers from your own environment: | Phone IVR | ⭐ | ⭐ | ⭐⭐⭐ | ⭐ | `openai` on Twilio | | Retail kiosk (noisy) | ⭐⭐ | ⭐⭐⭐ | ⭐ | ⭐ | `deepgram-eleven` on Local | | Multilingual hotline | ⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | `deepgram-eleven` on Twilio | -| Offline embedded device | ⭐⭐⭐ | ⭐⭐ | ⭐ | ⭐⭐⭐ | (future: local models) | +| Offline embedded device | ⭐⭐⭐ | ⭐⭐ | ⭐ | ⭐⭐⭐ | Custom local providers on Local | Cost is not a measured axis in this chapter — it's an annotation from provider pricing pages. Chapter 12 deliberately stops short diff --git a/examples/README.md b/examples/README.md index 9bbe48019..dcc6d2dc9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -45,10 +45,10 @@ emit the run or saved report inside the standard CLI envelope. `quickstart` includes local audio, OpenAI providers, the OpenAI Agents SDK, NumPy, ONNX Runtime, and LiveKit AEC3 echo cancellation. RNNoise is opt-in through the `rnnoise` extra. It does not install every framework/provider -variant. Install cells are repo-local commands that start with EasyCat extras, -such as `uv sync --extra quickstart --group dev`; anything after a semicolon is -an additional third-party package to install in the same environment with -`uv pip install`. +variant. Install cells start with a complete repo-local `uv sync` command. +After a semicolon, `--extra ` snippets extend that sync command; package +specifiers such as `langchain<1` or `krisp_audio` are installed in the same +environment with `uv pip install`. ## Choose An Example @@ -66,7 +66,7 @@ an additional third-party package to install in the same environment with | Example | Use When | Run | Install | Env | | --- | --- | --- | --- | --- | | [voice_app.py](voice_app.py) | App-first VoiceApp with one-word mode switching (browser/local/websocket/twilio). | `uv run python examples/voice_app.py` | `uv sync --extra quickstart --extra webrtc --group dev` | `OPENAI_API_KEY` | -| [voice_app_twilio.py](voice_app_twilio.py) | App-first Twilio phone bot via `VoiceApp.run("twilio")` and the reusable `easycat.telephony.server` helper. | `uv run python examples/voice_app_twilio.py` | `uv sync --extra openai --extra telephony --extra openai-agents --group dev` | `OPENAI_API_KEY`, `TWILIO_STREAM_URL`, `TWILIO_AUTH_TOKEN`; optional `TWILIO_STREAM_TOKEN_SECRET` | +| [voice_app_twilio.py](voice_app_twilio.py) | App-first Twilio phone bot via `VoiceApp.run("twilio")` and the reusable `easycat.telephony.server` helper. | `uv run python examples/voice_app_twilio.py` | `uv sync --extra openai --extra telephony --extra openai-agents --group dev` | `OPENAI_API_KEY`, `TWILIO_STREAM_URL`, `TWILIO_AUTH_TOKEN`; optional `TWILIO_STREAM_TOKEN_SECRET`, `TRUST_PROXY_HEADERS`, `TWILIO_PUBLIC_TWIML_URL` | | [openai_agents_voice.py](openai_agents_voice.py) | First local mic/speaker bot with OpenAI Agents SDK. | `uv run python examples/openai_agents_voice.py` | `uv sync --extra quickstart --group dev` | `OPENAI_API_KEY` | | [pydantic_ai_voice.py](pydantic_ai_voice.py) | Single-agent PydanticAI voice bot. | `uv run python examples/pydantic_ai_voice.py` | `uv sync --extra quickstart --extra pydantic-ai --group dev` | `OPENAI_API_KEY` | | [pydantic_ai_workflow_voice.py](pydantic_ai_workflow_voice.py) | Workflow-level PydanticAI hand-off across turns. | `uv run python examples/pydantic_ai_workflow_voice.py` | `uv sync --extra quickstart --extra pydantic-ai --group dev` | `OPENAI_API_KEY` | @@ -115,8 +115,9 @@ Support files: `ws_browser_example.py`. - [ws_supervisor_client.html](ws_supervisor_client.html): browser caller and supervisor client for `ws_supervisor_server.py`. -- [webrtc_static/webrtc_client.html](webrtc_static/webrtc_client.html): browser - client served by `webrtc_server.py`. +- [webrtc_static/webrtc_client.html](webrtc_static/webrtc_client.html): compact + browser client served by `webrtc_observability_server.py`; `webrtc_server.py` + uses the richer client bundled with the EasyCat package. - [webrtc_static/webrtc_observability.html](webrtc_static/webrtc_observability.html): combined WebRTC/debugger page served by `webrtc_observability_server.py`. - [webtransport_browser_client.html](webtransport_browser_client.html): browser diff --git a/examples/custom_transport.py b/examples/custom_transport.py index 360e1e6b1..b6d55fae4 100644 --- a/examples/custom_transport.py +++ b/examples/custom_transport.py @@ -25,6 +25,7 @@ from __future__ import annotations from collections.abc import AsyncIterator +from typing import Any from easycat import AudioChunk, AudioFormat, EasyConfig, require_env, run from easycat.transports import LocalTransport @@ -34,9 +35,9 @@ class CountingTransport: """Wraps a ``LocalTransport`` and counts the audio bytes flowing each way. Implements the ``Transport`` Protocol structurally — no base class. - The ``audio_format`` / ``clear_audio`` / ``default_echo_cancellation_enabled`` - members are optional transport capabilities, delegated so the wrapper - behaves exactly like the local transport it instruments. + Explicit methods instrument the core audio path; ``__getattr__`` delegates + every other optional capability so the wrapper preserves local playback, + delivery-reporting, event-bus, and AEC behavior. """ default_echo_cancellation_enabled = True @@ -46,6 +47,10 @@ def __init__(self, inner: LocalTransport) -> None: self.bytes_in = 0 self.bytes_out = 0 + def __getattr__(self, name: str) -> Any: + """Delegate optional transport capabilities to the wrapped transport.""" + return getattr(self._inner, name) + async def connect(self) -> None: await self._inner.connect() diff --git a/examples/ec2_webrtc/deploy.sh b/examples/ec2_webrtc/deploy.sh index afa784443..3a29028ff 100755 --- a/examples/ec2_webrtc/deploy.sh +++ b/examples/ec2_webrtc/deploy.sh @@ -49,7 +49,10 @@ if [ -z "$EXTERNAL_IP" ]; then fi TURN_PASSWORD="${TURN_PASSWORD:-$(openssl rand -base64 24)}" -WEBRTC_SIGNALING_TOKEN="${WEBRTC_SIGNALING_TOKEN:-$(openssl rand -base64 32)}" +# Keep the generated browser bootstrap token URL-safe. URLSearchParams treats +# an unescaped "+" in an encoded value as a space, which corrupts standard Base64 +# tokens before the client forwards them in the Authorization header. +WEBRTC_SIGNALING_TOKEN="${WEBRTC_SIGNALING_TOKEN:-$(openssl rand -hex 32)}" OPENAI_API_KEY="${OPENAI_API_KEY:?Set OPENAI_API_KEY before running this script}" INSTALL_DIR="/opt/easycat" @@ -184,13 +187,15 @@ echo "" echo "=== Deployment complete ===" echo "" echo " Backend HTTP URL: http://$EXTERNAL_IP:8080/webrtc_client.html" -echo " Browser URL: https:///webrtc_client.html?token=" +echo " Browser URL: https:///webrtc_client.html#token=" +echo " Percent-encode the value first if you supplied a custom token." echo " Signaling URL: https:// (after TLS proxy)" echo " TURN server: turn:$EXTERNAL_IP:3478" echo " TURN user: easycat" echo " TURN password: $TURN_PASSWORD" -echo " Browser TURN auth remains hidden from /config by default." -echo " Set WEBRTC_EXPOSE_ICE_CREDENTIALS=1 only for trusted demos or short-lived TURN creds." +echo " Browser TURN entries are omitted from /config by default (server-side relay only)." +echo " Clients that require their own relay need short-lived TURN credentials." +echo " Set WEBRTC_EXPOSE_ICE_CREDENTIALS=1 only for trusted demos or short-lived credentials." echo "" echo " Check status: sudo systemctl status easycat-webrtc" echo " View logs: sudo journalctl -u easycat-webrtc -f" diff --git a/examples/noise_reduction_backends.py b/examples/noise_reduction_backends.py index 0ceadbf27..2dc497a31 100644 --- a/examples/noise_reduction_backends.py +++ b/examples/noise_reduction_backends.py @@ -5,6 +5,7 @@ Setup: export OPENAI_API_KEY=...; uv sync --extra quickstart --extra rnnoise --group dev + uv pip install krisp_audio # required only for --backend krisp (commercial SDK) uv run easycat doctor uv run easycat doctor --env-file .env # if keys live in .env uv run easycat doctor --env-file .env --json # for parseable checks @@ -13,8 +14,6 @@ # Other choices: --backend krisp or --backend auto """ -from typing import Literal - try: from agents import Agent # type: ignore[import-untyped] except ImportError as exc: @@ -25,12 +24,14 @@ ) from exc from easycat import EasyConfig, run -from easycat.noise_reduction import NoiseReducerConfig, create_noise_reducer - -NoiseReductionBackend = Literal["auto", "krisp", "rnnoise"] +from easycat.noise_reduction import ( + NoiseReducerBackend, + NoiseReducerConfig, + create_noise_reducer, +) -def main(backend: NoiseReductionBackend) -> None: +def main(backend: NoiseReducerBackend) -> None: # Probe so you can see which class actually resolved before the session starts. # Close the probe afterwards: Krisp/RNNoise reducers hold native # resources (and Krisp may allow only one session at a time), so diff --git a/examples/vad_backends.py b/examples/vad_backends.py index fe5ebc1f7..9931fe2a2 100644 --- a/examples/vad_backends.py +++ b/examples/vad_backends.py @@ -12,9 +12,8 @@ Setup: export OPENAI_API_KEY="..." uv sync --extra quickstart --group dev # bundled Silero ONNX - uv sync --extra funasr-vad --group dev # bundled FunASR ONNX + uv sync --extra quickstart --extra funasr-vad --group dev # bundled FunASR ONNX uv sync --extra quickstart --extra ten-vad --group dev # separate TEN VAD license - uv sync --extra silero-vad --group dev # bundled Silero ONNX uv pip install krisp_audio # Krisp SDK uv run easycat doctor uv run easycat doctor --env-file .env # if keys live in .env @@ -28,7 +27,6 @@ from __future__ import annotations import argparse -from typing import Literal try: from agents import Agent # type: ignore[import-untyped] @@ -36,12 +34,12 @@ Agent = None # type: ignore[assignment] from easycat import EasyConfig, run -from easycat.vad import VADConfig, create_vad +from easycat.vad import VADBackend, VADConfig, create_vad -BACKENDS = ("auto", "silero", "funasr", "ten", "krisp") +BACKENDS: tuple[VADBackend, ...] = ("auto", "silero", "funasr", "ten", "krisp") -def main(backend: Literal["auto", "silero", "funasr", "ten", "krisp"]) -> None: +def main(backend: VADBackend) -> None: if Agent is None: raise SystemExit( "openai-agents is required. For an app, run: " diff --git a/examples/voice_app_twilio.py b/examples/voice_app_twilio.py index c29d6c277..a5390ccf4 100644 --- a/examples/voice_app_twilio.py +++ b/examples/voice_app_twilio.py @@ -8,6 +8,8 @@ Setup: export OPENAI_API_KEY=...; export TWILIO_STREAM_URL=wss://your-host:8766 export TWILIO_AUTH_TOKEN=... # signs/validates the POST /twiml webhook + # Behind a TLS proxy: export TRUST_PROXY_HEADERS=1 + # Or pin the signed URL: export TWILIO_PUBLIC_TWIML_URL=https://your-host/twiml uv sync --extra openai --extra telephony --extra openai-agents --group dev uv run easycat doctor uv run easycat doctor --env-file .env # if keys live in .env @@ -19,8 +21,9 @@ ``POST /twiml`` mints a media stream token on every request, so the webhook is authenticated by default: ``TWILIO_AUTH_TOKEN`` validates the ``X-Twilio-Signature`` header before a token is issued. Behind a TLS-terminating -proxy, also pass ``trust_proxy_headers=True`` so the signed public URL is -reconstructed from the forwarded headers. +proxy, set ``TRUST_PROXY_HEADERS=1`` so the signed public URL is reconstructed +from forwarded headers, or set ``TWILIO_PUBLIC_TWIML_URL`` to the exact public +route used in Twilio's request signature. For the lower-level FastAPI reference (outbound calls, status callbacks, SMS), see examples/twilio_app.py. diff --git a/examples/webrtc_observability_server.py b/examples/webrtc_observability_server.py index 18fa397ee..1d4a6d634 100644 --- a/examples/webrtc_observability_server.py +++ b/examples/webrtc_observability_server.py @@ -21,6 +21,9 @@ debugger UI below) http://localhost:8080/webrtc_client.html (just the bot) http://localhost:8090 (just the debugger) + +When ``WEBRTC_SIGNALING_TOKEN`` is set, use the ``#token=`` URLs printed at +startup and replace the placeholder with the URL-encoded token value. """ from __future__ import annotations @@ -88,16 +91,16 @@ def main() -> None: allow_remote=debugger_host not in ("127.0.0.1", "localhost"), ) - token_query = "&token=" if transport.auth_token else "" + token_fragment = "#token=" if transport.auth_token else "" combined_url = ( f"http://localhost:{transport.port}/webrtc_observability.html" - f"?debugger_port={debugger_port}{token_query}" + f"?debugger_port={debugger_port}{token_fragment}" ) - client_token_query = "?token=" if transport.auth_token else "" + client_token_fragment = "#token=" if transport.auth_token else "" print(f"WebRTC + debugger: {combined_url}") print( f"WebRTC client only: " - f"http://localhost:{transport.port}/webrtc_client.html{client_token_query}" + f"http://localhost:{transport.port}/webrtc_client.html{client_token_fragment}" ) print(f"Debugger only: http://{debugger_host}:{debugger_port}") diff --git a/examples/webrtc_server.py b/examples/webrtc_server.py index 85bef4413..343b1c317 100644 --- a/examples/webrtc_server.py +++ b/examples/webrtc_server.py @@ -23,15 +23,19 @@ TURN_SERVER_URL — Optional. TURN server URL (e.g. turn:1.2.3.4:3478). TURN_USERNAME — Optional. TURN server username. TURN_CREDENTIAL — Optional. TURN server credential. - WEBRTC_EXPOSE_ICE_CREDENTIALS — Optional. Set to 1 to return TURN - credentials from /config. Use only with - trusted demos or short-lived credentials. + WEBRTC_EXPOSE_ICE_CREDENTIALS — Optional. Set to 1 to return complete TURN + entries from /config. Use only with + trusted demos or short-lived credentials; + otherwise browser config is STUN-only and + TURN is server-side relay only. SIGNALING_HOST — Optional. Bind address (default 127.0.0.1). SIGNALING_PORT — Optional. Listen port (default 8080). WEBRTC_SIGNALING_TOKEN — Optional on localhost; required for public binds. WEBRTC_MAX_SESSIONS — Optional. Concurrent browser sessions (default 64). -Then open http://localhost:8080 in your browser. +Then open http://localhost:8080 in your browser. When +``WEBRTC_SIGNALING_TOKEN`` is set, use the printed ``#token=`` bootstrap URL and +replace its placeholder with the URL-encoded token value. The bundled client is same-origin with the signaling server. If you host a custom browser UI elsewhere, pass explicit cors_allowed_origins to WebRTCTransportConfig instead of relying on wildcard CORS. @@ -61,14 +65,15 @@ def config(transport): return EasyConfig.browser(transport=transport, agent=agent) transport = webrtc_transport_config_from_env() - token_hint = "?token=" if transport.auth_token else "" + token_hint = "#token=" if transport.auth_token else "" print(f"Open http://localhost:{transport.port}/webrtc_client.html{token_hint} in your browser") - if any(any("turn:" in u for u in s.urls) for s in transport.ice_servers): + ice_urls = (url for server in transport.ice_servers for url in server.urls) + if any(url.lower().startswith(("turn:", "turns:")) for url in ice_urls): print("TURN server: configured") if transport.expose_ice_credentials: - print("TURN auth: exposed via /config") + print("TURN browser: /config includes only entries with complete credentials") else: - print("TURN auth: hidden from /config") + print("TURN browser: omitted from /config (server-side relay only)") else: print("TURN server: not configured (STUN only — NAT traversal may fail)") diff --git a/examples/webrtc_static/webrtc_client.html b/examples/webrtc_static/webrtc_client.html index 10153cea2..161eb2e63 100644 --- a/examples/webrtc_static/webrtc_client.html +++ b/examples/webrtc_static/webrtc_client.html @@ -3,6 +3,7 @@ + EasyCat WebRTC Voice Chat