-
Notifications
You must be signed in to change notification settings - Fork 0
P0 production hardening: Twilio auth, voice-call metrics, Docker/CI fixes #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
379d114
9b4e428
57cfae8
98518ef
bc04aad
395070d
33cb5bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,6 +36,17 @@ | |
| "meta-llama/llama-3.3-70b-instruct": (0.59, 0.0, 0.79), | ||
| "google/gemini-2.0-flash": (0.10, 0.0, 0.40), | ||
| "deepseek/deepseek-r1": (0.55, 0.0, 2.19), | ||
|
|
||
| # OpenAI direct — the streaming voice path's LLM (apps/voice/streaming.py) | ||
| "gpt-4o-mini": (0.15, 0.075, 0.60), | ||
| } | ||
|
|
||
| # Non-token voice pricing (July 2026 published pay-as-you-go rates). | ||
| VOICE_PRICES_USD: dict[str, float] = { | ||
| # Deepgram streaming STT — USD per audio-minute | ||
| "deepgram/nova-3-general": 0.0077, | ||
| # Rime Mist v2 TTS — USD per character ($0.03 / 1k chars) | ||
| "rime/mistv2": 0.03 / 1000, | ||
|
Comment on lines
+39
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: As of July 10, 2026, the pay-as-you-go pricing for Deepgram Nova-3 is as follows: * Nova-3 (Monolingual): $0.0077 per minute [1][2][3][4]. * Nova-3 (Multilingual): $0.0092 per minute [4]. These rates apply to both real-time streaming and batch processing [1][4]. Deepgram utilizes per-second billing precision [4]. Pricing can vary based on factors such as the number of audio channels being processed, as costs multiply by the channel count [3]. Additional information regarding billing and plans can be found on Deepgram's official pricing page [5][6]. Citations:
🌐 Web query:
💡 Result: As of July 10, 2026, Rime AI does not publish a single flat rate per character for the Mist model, as its pricing is structured primarily through tiered monthly subscription plans rather than a universal pay-as-you-go rate [1][2]. While older documentation and third-party cost trackers have historically cited various pay-as-you-go rates (such as $20 to $30 per million characters) [3][4][2], Rime's current official pricing model is built around the following subscription tiers: - Starter: $5/month for 100,000 characters - Developer: $19/month for 500,000 characters - Pro: $99/month for 3,000,000 characters - Business: $249/month for 10,000,000 characters - Enterprise: Custom pricing for high-volume requirements [1][2]. For users requiring specific per-character rates, particularly those exceeding standard tiers, Rime provides custom packages through their Enterprise and high-volume sales channels [5][1]. You can find the most current and account-specific details by visiting the official Rime pricing page at rime.ai/pricing [5]. Citations:
🌐 Web query:
💡 Result: Rime AI's pricing for its text-to-speech services starts at $0.05 per 1,000 characters [1]. The platform also offers volume pricing for larger organizations operating at scale [1]. Users can begin with a free plan that includes 3,000 minutes of usage [1]. Citations: 🌐 Web query:
💡 Result: The official pricing for the Deepgram Nova-3 Monolingual model is $0.0078 per minute [1]. Your reference to $0.0077 per minute does not match the current official pricing listed on the Deepgram website [1]. Citations: 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the relevant file and nearby references to the pricing constants.
git ls-files agents/cost_meter.py
wc -l agents/cost_meter.py
sed -n '1,140p' agents/cost_meter.py
printf '\n--- references ---\n'
rg -n "VOICE_PRICES_USD|deepgram/nova-3-general|rime/mistv2|gpt-4o-mini|cost_meter" agents -nRepository: teetangh/defaultline Length of output: 5463 Update the voice pricing constants in
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
|
|
||
|
|
@@ -89,29 +100,79 @@ class CostMeter: | |
| """JSONL-append cost log. Thread-safe.""" | ||
|
|
||
| _lock: ClassVar[threading.Lock] = threading.Lock() | ||
| _path: ClassVar[Path] = Path(os.environ.get("COST_LOG", "data/eval_runs/cost_log.jsonl")) | ||
|
|
||
| @classmethod | ||
| def _log_path(cls) -> Path: | ||
| # Resolved at call time so setting COST_LOG after import works | ||
| # (the previous ClassVar froze the path at import). | ||
| return Path(os.environ.get("COST_LOG", "data/eval_runs/cost_log.jsonl")) | ||
|
|
||
| @classmethod | ||
| def _append(cls, rec: CostRecord) -> None: | ||
| path = cls._log_path() | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| with cls._lock, path.open("a") as f: | ||
| f.write(json.dumps(asdict(rec)) + "\n") | ||
|
|
||
| @classmethod | ||
| def record(cls, role: str, resp: LLMResponse, label: str = "") -> CostRecord: | ||
| rec = _price_response(role, resp) | ||
| rec.label = label | ||
| cls._path.parent.mkdir(parents=True, exist_ok=True) | ||
| with cls._lock, cls._path.open("a") as f: | ||
| f.write(json.dumps(asdict(rec)) + "\n") | ||
| cls._append(rec) | ||
| return rec | ||
|
|
||
| @classmethod | ||
| def record_usage( | ||
| cls, | ||
| *, | ||
| role: str, | ||
| model: str, | ||
| input_tokens: int = 0, | ||
| cached_input_tokens: int = 0, | ||
| output_tokens: int = 0, | ||
| cost_usd: float | None = None, | ||
| label: str = "", | ||
| ) -> CostRecord: | ||
| """Generic entry point for usage that doesn't arrive as an LLMResponse | ||
| (the streaming voice pipeline's LLM/STT/TTS). When ``cost_usd`` is | ||
| given it is recorded as-is (STT minutes / TTS characters priced by | ||
| the caller); otherwise it is priced from PRICES_USD token rates. | ||
| """ | ||
| if cost_usd is None: | ||
| in_p, cached_p, out_p = _lookup_prices(model) | ||
| uncached = max(0, input_tokens - cached_input_tokens) | ||
| cost_usd = ( | ||
| uncached * in_p / 1_000_000 | ||
| + cached_input_tokens * cached_p / 1_000_000 | ||
| + output_tokens * out_p / 1_000_000 | ||
| ) | ||
| rec = CostRecord( | ||
| ts=time.time(), | ||
| role=role, | ||
| model=model, | ||
| input_tokens=input_tokens, | ||
| cached_input_tokens=cached_input_tokens, | ||
| output_tokens=output_tokens, | ||
| cost_usd=cost_usd, | ||
| label=label, | ||
| ) | ||
| cls._append(rec) | ||
| return rec | ||
|
|
||
| @classmethod | ||
| def total_usd(cls) -> float: | ||
| if not cls._path.exists(): | ||
| path = cls._log_path() | ||
| if not path.exists(): | ||
| return 0.0 | ||
| return sum(json.loads(line)["cost_usd"] for line in cls._path.read_text().splitlines()) | ||
| return sum(json.loads(line)["cost_usd"] for line in path.read_text().splitlines()) | ||
|
|
||
| @classmethod | ||
| def breakdown(cls) -> dict[str, float]: | ||
| if not cls._path.exists(): | ||
| path = cls._log_path() | ||
| if not path.exists(): | ||
| return {} | ||
| totals: dict[str, float] = {} | ||
| for line in cls._path.read_text().splitlines(): | ||
| for line in path.read_text().splitlines(): | ||
| r = json.loads(line) | ||
| key = f"{r['role']}/{r['model']}" | ||
| totals[key] = totals.get(key, 0.0) + r["cost_usd"] | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,100 @@ | ||||||||||||||||||||||||||||||
| """Twilio request authentication for the /voice/* surface. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Two mechanisms: | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 1. HTTP webhooks (X-Twilio-Signature). Twilio signs every webhook with | ||||||||||||||||||||||||||||||
| HMAC-SHA1 over the exact public URL it requested plus the sorted POST | ||||||||||||||||||||||||||||||
| form params, keyed by the account's auth token. Behind a tunnel the | ||||||||||||||||||||||||||||||
| internal ``request.url`` differs from what Twilio signed, so the check | ||||||||||||||||||||||||||||||
| reconstructs the public URL from WEBHOOK_BASE_URL (same convention as | ||||||||||||||||||||||||||||||
| ``voice_twiml._base_url``). | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 2. Media Streams WebSocket. Twilio's WS handshake carries no signature, | ||||||||||||||||||||||||||||||
| so the <Stream url> embeds a short-lived-enough token: a stateless | ||||||||||||||||||||||||||||||
| HMAC-SHA256 over the session id, keyed by TWILIO_AUTH_TOKEN. Both the | ||||||||||||||||||||||||||||||
| TwiML endpoint and the WS endpoint derive it independently — no shared | ||||||||||||||||||||||||||||||
| state, survives restarts and multiple workers. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Enforcement is controlled by TWILIO_WEBHOOK_AUTH: | ||||||||||||||||||||||||||||||
| "auto" (default) — enforced iff TWILIO_AUTH_TOKEN is configured | ||||||||||||||||||||||||||||||
| "on" / "off" — explicit override (simulators/tests use "off", or | ||||||||||||||||||||||||||||||
| sign their requests like the TwiML simulator does) | ||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import hashlib | ||||||||||||||||||||||||||||||
| import hmac | ||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from fastapi import HTTPException, Request | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from agents.settings import get_secrets | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def auth_enabled() -> bool: | ||||||||||||||||||||||||||||||
| flag = os.environ.get("TWILIO_WEBHOOK_AUTH", "auto").lower() | ||||||||||||||||||||||||||||||
| if flag in ("off", "false", "0"): | ||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||
| if flag in ("on", "true", "1"): | ||||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||||
| return bool(get_secrets().TWILIO_AUTH_TOKEN) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _public_url(request: Request) -> str: | ||||||||||||||||||||||||||||||
| """The URL Twilio actually signed — public base + path + query.""" | ||||||||||||||||||||||||||||||
| base = os.environ.get("WEBHOOK_BASE_URL", "").rstrip("/") | ||||||||||||||||||||||||||||||
| if base: | ||||||||||||||||||||||||||||||
| url = f"{base}{request.url.path}" | ||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||
| url = f"{request.url.scheme}://{request.url.netloc}{request.url.path}" | ||||||||||||||||||||||||||||||
| if request.url.query: | ||||||||||||||||||||||||||||||
| url += f"?{request.url.query}" | ||||||||||||||||||||||||||||||
| return url | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| async def verify_twilio_webhook(request: Request) -> None: | ||||||||||||||||||||||||||||||
| """FastAPI dependency for Twilio-called HTTP webhook routes. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Raises 403 unless the request carries a valid X-Twilio-Signature. | ||||||||||||||||||||||||||||||
| Reading the form here is safe — Starlette caches it, so endpoint | ||||||||||||||||||||||||||||||
| Form(...) parameters still parse. | ||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||
| if not auth_enabled(): | ||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||
| token = get_secrets().TWILIO_AUTH_TOKEN | ||||||||||||||||||||||||||||||
| if not token: | ||||||||||||||||||||||||||||||
| raise HTTPException( | ||||||||||||||||||||||||||||||
| status_code=403, | ||||||||||||||||||||||||||||||
| detail="Twilio webhook auth is on but TWILIO_AUTH_TOKEN is not configured", | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||
| from twilio.request_validator import RequestValidator | ||||||||||||||||||||||||||||||
| except ImportError as exc: # twilio ships with the voice extra only | ||||||||||||||||||||||||||||||
| raise RuntimeError( | ||||||||||||||||||||||||||||||
| "Twilio webhook auth is enabled but the twilio SDK is not " | ||||||||||||||||||||||||||||||
| "installed. Install with `pip install -e '.[voice]'` or set " | ||||||||||||||||||||||||||||||
| "TWILIO_WEBHOOK_AUTH=off." | ||||||||||||||||||||||||||||||
| ) from exc | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| params: dict[str, str] = {} | ||||||||||||||||||||||||||||||
| if request.method == "POST": | ||||||||||||||||||||||||||||||
| content_type = request.headers.get("content-type", "") | ||||||||||||||||||||||||||||||
| if "application/x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type: | ||||||||||||||||||||||||||||||
| params = {k: str(v) for k, v in (await request.form()).items()} | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| validator = RequestValidator(token) | ||||||||||||||||||||||||||||||
| signature = request.headers.get("X-Twilio-Signature", "") | ||||||||||||||||||||||||||||||
| if not validator.validate(_public_url(request), params, signature): | ||||||||||||||||||||||||||||||
| raise HTTPException(status_code=403, detail="invalid Twilio signature") | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def stream_token(session_id: str) -> str: | ||||||||||||||||||||||||||||||
| """Stateless auth token for the Media Streams WS endpoint.""" | ||||||||||||||||||||||||||||||
| secret = get_secrets().TWILIO_AUTH_TOKEN | ||||||||||||||||||||||||||||||
| return hmac.new( | ||||||||||||||||||||||||||||||
| secret.encode(), f"stream:{session_id}".encode(), hashlib.sha256, | ||||||||||||||||||||||||||||||
| ).hexdigest()[:32] | ||||||||||||||||||||||||||||||
|
Comment on lines
+91
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def verify_stream_token(session_id: str, token: str) -> bool: | ||||||||||||||||||||||||||||||
| return hmac.compare_digest(stream_token(session_id), token or "") | ||||||||||||||||||||||||||||||
|
Comment on lines
+91
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Locate the relevant files and inspect the surrounding code.
git ls-files | rg 'apps/api/(twilio_security\.py|voice_twiml\.py|streaming\.py)$'
echo
echo '--- apps/api/twilio_security.py ---'
wc -l apps/api/twilio_security.py
cat -n apps/api/twilio_security.py | sed -n '1,180p'
echo
echo '--- apps/api/voice_twiml.py ---'
wc -l apps/api/voice_twiml.py
cat -n apps/api/voice_twiml.py | sed -n '1,220p'
echo
echo '--- apps/api/streaming.py ---'
wc -l apps/api/streaming.py
cat -n apps/api/streaming.py | sed -n '1,240p'Repository: teetangh/defaultline Length of output: 15160 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Find every use of the stream token helpers and the streaming endpoints.
rg -n "stream_token|verify_stream_token|TWILIO_AUTH_TOKEN|TWILIO_WEBHOOK_AUTH|streaming_ws|streaming_dial_init|streaming_twiml" apps api . -g '!**/.git/**'
echo
echo '--- apps/api/voice_twiml.py around streaming routes ---'
sed -n '220,420p' apps/api/voice_twiml.py | cat -n
echo
echo '--- any file named *stream* or *twilio* in apps/api ---'
find apps/api -maxdepth 1 -type f | rg 'stream|twilio'Repository: teetangh/defaultline Length of output: 7473 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the streaming call sites and the settings default for TWILIO_AUTH_TOKEN.
sed -n '380,460p' apps/api/voice_twiml.py | cat -n
echo
sed -n '400,500p' apps/voice/streaming.py | cat -n
echo
sed -n '90,130p' agents/settings.py | cat -n
echo
sed -n '1,220p' tests/test_twilio_security.py | cat -nRepository: teetangh/defaultline Length of output: 17392 Guard 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Set
persist-credentials: falseon checkout.The smoke job doesn't push, so the persisted
GITHUB_TOKENon the checked-out repo is unnecessary attack surface. Flagged by zizmor (artipacked).🔒 Proposed hardening
📝 Committable suggestion
🧰 Tools
🪛 zizmor (1.26.1)
[warning] 60-60: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🤖 Prompt for AI Agents
Source: Linters/SAST tools