Skip to content
Open
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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ RIME_API_KEY=
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_PHONE_NUMBER=+1...
# Groq — required by settings.yaml llm.agent_voice (low-latency voice agent role)
GROQ_API_KEY=gsk_...
# Twilio webhook auth for /voice/* routes: auto (default; enforced when
# TWILIO_AUTH_TOKEN is set) | on | off
TWILIO_WEBHOOK_AUTH=auto
# Public base URL Twilio calls back to (cloudflared/ngrok tunnel or stable
# host). Required for signature validation behind a tunnel — Twilio signs
# the public URL, not the internal uvicorn one.
WEBHOOK_BASE_URL=https://<your-tunnel>.trycloudflare.com

# Infra
TEMPORAL_HOST=temporal:7233
Expand Down
58 changes: 41 additions & 17 deletions .github/workflows/voice-smoke.yml → .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,33 +1,35 @@
# Voice-pipeline smoke tests — disabled by default.
# CI — unit tests on every push/PR (no secrets needed), plus the
# voice-pipeline integration smokes behind manual dispatch.
#
# Runs both simulator-based smoke tests against live external APIs (OpenAI,
# OpenRouter, Rime, Deepgram) without ever dialing Twilio. Catches drift in
# the upstream APIs (e.g. Rime renaming voice IDs, Pipecat moving classes)
# that unit tests would miss.
# The smoke job runs both simulator-based smoke tests against live external
# APIs (OpenAI, OpenRouter, Rime, Deepgram) without ever dialing Twilio.
# Catches drift in the upstream APIs (e.g. Rime renaming voice IDs, Pipecat
# moving classes) that unit tests would miss.
#
# To enable:
# To run the smokes:
# 1. Add these repo secrets at github.com/<owner>/<repo>/settings/secrets/actions:
# OPENROUTER_API_KEY, OPENAI_API_KEY, RIME_API_KEY, DEEPGRAM_API_KEY
# 2. Change the `on:` trigger below from `workflow_dispatch:` (manual only)
# to `schedule: - cron: '0 4 * * *'` for nightly automatic runs.
# 3. Remove the top-level `if: false` guard.
# 2. Trigger the workflow manually (Actions -> ci -> Run workflow), or
# add `schedule: - cron: '0 4 * * *'` for nightly automatic runs.
#
# Cost per run: ~$0.06 (OpenAI gpt-4o-mini for streaming, OpenRouter free
# tier for TwiML if available). Free Deepgram + Rime quotas cover the rest.
# Cost per smoke run: ~$0.06 (OpenAI gpt-4o-mini for streaming, OpenRouter
# free tier for TwiML if available). Free Deepgram + Rime quotas cover the rest.

name: voice-smoke
name: ci

on:
workflow_dispatch: # manual-only by default
push:
branches: [main]
pull_request:
workflow_dispatch: # enables the smoke job

permissions:
contents: read

jobs:
smoke:
if: false # safety: remove this line to enable
unit:
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20

steps:
- uses: actions/checkout@v4
Expand All @@ -38,13 +40,35 @@ jobs:
python-version: "3.12"
cache: pip

# voice extras are required by the unit suite too: test_propose_offer /
# test_tool_call_filter / test_twilio_security import apps.voice.streaming.
- name: Install runtime + voice extras
run: |
python -m pip install --upgrade pip
pip install -e '.[voice,dev]'

- name: Run unit tests
run: pytest -m "not integration" -q
run: pytest -q # addopts already excludes -m integration

smoke:
if: github.event_name == 'workflow_dispatch'
needs: unit
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown

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: false on checkout.

The smoke job doesn't push, so the persisted GITHUB_TOKEN on the checked-out repo is unnecessary attack surface. Flagged by zizmor (artipacked).

🔒 Proposed hardening
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
persist-credentials: false
🧰 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml at line 60, Update the checkout step in the smoke
job to set persist-credentials: false on actions/checkout@v4, preventing
unnecessary GITHUB_TOKEN credentials from being stored in the repository
configuration.

Source: Linters/SAST tools


- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install runtime + voice extras
run: |
python -m pip install --upgrade pip
pip install -e '.[voice,dev]'

- name: Boot API
env:
Expand Down
8 changes: 6 additions & 2 deletions Dockerfile.api
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ WORKDIR /app

RUN pip install --no-cache-dir uv

# Sources must be present before the install: hatchling builds a wheel from
# packages = ["agents", "apps", "learning"] and fails if they're missing.
# The [voice] extra pulls pipecat + twilio, which apps.api.main imports via
# the streaming voice router.
COPY pyproject.toml ./
RUN uv pip install --system --no-cache .

COPY agents ./agents
COPY apps ./apps
COPY learning ./learning
RUN uv pip install --system --no-cache '.[voice]'

COPY data ./data
COPY settings.yaml ./

Expand Down
8 changes: 6 additions & 2 deletions Dockerfile.worker
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ WORKDIR /app

RUN pip install --no-cache-dir uv

# Sources must be present before the install: hatchling builds a wheel from
# packages = ["agents", "apps", "learning"] and fails if they're missing.
# The worker needs [voice] too — resolve_via_voice imports PipecatProvider
# when settings.yaml voice.provider is "pipecat".
COPY pyproject.toml ./
RUN uv pip install --system --no-cache .

COPY agents ./agents
COPY apps ./apps
COPY learning ./learning
RUN uv pip install --system --no-cache '.[voice]'

COPY data ./data
COPY settings.yaml ./

Expand Down
77 changes: 69 additions & 8 deletions agents/cost_meter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What is the current Deepgram nova-3 pay-as-you-go pricing per minute?

💡 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:

What is the current Rime TTS mist v2 pricing per character?

💡 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:

site:rime.ai/pricing Rime pricing mist per character official

💡 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:

site:deepgram.com/pricing Nova-3 monolingual $0.0077 per minute official

💡 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 -n

Repository: teetangh/defaultline

Length of output: 5463


Update the voice pricing constants in agents/cost_meter.py:45-49

  • deepgram/nova-3-general should be 0.0078 per minute.
  • rime/mistv2 should use 0.05 / 1000 per character; 0.03 / 1000 is stale.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agents/cost_meter.py` around lines 39 - 49, Update the VOICE_PRICES_USD
constants for “deepgram/nova-3-general” to 0.0078 per audio-minute and
“rime/mistv2” to 0.05 / 1000 per character, replacing the stale values.

}


Expand Down Expand Up @@ -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"]
Expand Down
5 changes: 4 additions & 1 deletion agents/prompt_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,12 @@ def list_versions(agent: AgentName) -> list[PromptVersion]:
agent_dir = _agent_dir(agent)
if not agent_dir.exists():
return versions
for path in sorted(agent_dir.glob("v*.yaml")):
for path in agent_dir.glob("v*.yaml"):
raw = yaml.safe_load(path.read_text())
versions.append(PromptVersion.model_validate(raw))
# Sort numerically by the declared version, not by filename — lexical
# glob order puts v10 before v2.
versions.sort(key=lambda v: v.version)
return versions


Expand Down
3 changes: 2 additions & 1 deletion agents/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ class Offer(BaseModel):
conditions: str = ""

def short(self) -> str:
return f"{self.type.value}:${self.amount:.0f}/{self.expiry_days}d"
base = f"{self.type.value}:${self.amount:.0f}"
return f"{base}/{self.expiry_days}d" if self.expiry_days is not None else base


class Objection(BaseModel):
Expand Down
100 changes: 100 additions & 0 deletions apps/api/twilio_security.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If TWILIO_WEBHOOK_AUTH is set to 'on' but TWILIO_AUTH_TOKEN is not configured in the environment, get_secrets().TWILIO_AUTH_TOKEN will be None. In this scenario, calling secret.encode() will raise an AttributeError: 'NoneType' object has no attribute 'encode'. Adding a check to ensure secret is configured before calling .encode() prevents this potential crash.

Suggested change
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]
def stream_token(session_id: str) -> str:
"""Stateless auth token for the Media Streams WS endpoint."""
secret = get_secrets().TWILIO_AUTH_TOKEN
if not secret:
raise RuntimeError("TWILIO_AUTH_TOKEN is not configured")
return hmac.new(
secret.encode(), f"stream:{session_id}".encode(), hashlib.sha256,
).hexdigest()[:32]



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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 -n

Repository: teetangh/defaultline

Length of output: 17392


Guard stream_token() when TWILIO_AUTH_TOKEN is missing.
With TWILIO_WEBHOOK_AUTH=on and no auth token configured, this still signs with the empty string, so the WS token is predictable and verify_stream_token() accepts forgeable tokens. Mirror the webhook guard here and reject missing TWILIO_AUTH_TOKEN before signing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/twilio_security.py` around lines 91 - 100, Update stream_token() to
validate that TWILIO_AUTH_TOKEN is present before creating the HMAC, mirroring
the existing webhook authentication guard; reject missing or empty credentials
rather than signing with an empty secret, and ensure verify_stream_token()
propagates that rejection.

Loading
Loading