P0 production hardening: Twilio auth, voice-call metrics, Docker/CI fixes - #13
P0 production hardening: Twilio auth, voice-call metrics, Docker/CI fixes#13teetangh wants to merge 7 commits into
Conversation
… docstrings - list_versions sorted lexically by filename so v10 ordered before v2; now sorts numerically by the declared version field - Offer.short() rendered '/Noned' when expiry_days was unset - streaming.py + place_streaming_voice_call.py docstrings still said OpenRouter LLM / MarkerStripperProcessor; actual wiring is OpenAI gpt-4o-mini + the end_call tool Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in .env.example - pipecat_provider read six secrets via bare os.environ[...] which crashed with KeyError; now uses the existing Secrets settings object with a _require() helper raising actionable RuntimeErrors - secrets are validated before the pipecat imports so misconfiguration fails deterministically with the most useful message first - .env.example gains GROQ_API_KEY (required by settings.yaml llm.agent_voice but previously undocumented), TWILIO_WEBHOOK_AUTH, and WEBHOOK_BASE_URL Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…S token Previously every /voice/* webhook and the streaming WebSocket were unauthenticated — anyone who found the tunnel URL could drive calls or attach a pipeline. - apps/api/twilio_security.py: X-Twilio-Signature validation as a FastAPI dependency (validates against the public WEBHOOK_BASE_URL Twilio actually signed) + stateless HMAC stream token for the WS - enforcement on Twilio-called routes only (twiml/gather/wait/status/ streaming-twiml); operator endpoints (dial-init/state) remain open — operator auth tracked separately - WS endpoint rejects bad tokens pre-accept (403 handshake) before any pipeline resources are built; <Stream url> carries ?token=... - TWILIO_WEBHOOK_AUTH=auto|on|off (auto: enforced iff TWILIO_AUTH_TOKEN is set) keeps local dev and CI hermetic - simulators keep working under enforcement: the streaming simulator uses the ws_token from dial-init; the TwiML simulator signs its requests with the documented HMAC-SHA1 algorithm Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ge, COST_LOG fix The streaming pipeline generated Pipecat MetricsFrames (enable_metrics/enable_usage_metrics) and dropped them; streaming calls were also invisible to the cost ledger. - apps/voice/call_metrics.py: VoiceCallMetricsObserver collects per-stage TTFB/processing stats + LLM token and TTS character usage (deduped on frame.id — observers see a frame once per pipeline edge) and writes a per-call summary to data/eval_runs/voice_call_metrics.jsonl - finalize() mirrors voice_llm/voice_stt/voice_tts costs into CostMeter so scripts/cost_report.py covers voice calls; STT priced by wall-clock audio-minutes (documented approximation), TTS by characters - CostMeter.record_usage(): generic entry point for non-LLMResponse usage; PRICES_USD gains gpt-4o-mini; new VOICE_PRICES_USD table (Deepgram nova-3 $0.0077/min, Rime mistv2 $0.03/1k chars) - fix: COST_LOG was resolved at import into a ClassVar, so configuring it later silently wrote to the default path; now resolved per call Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…worker images The previous order (COPY pyproject.toml -> uv pip install . -> COPY sources) failed the hatchling wheel build outright — packages = [agents, apps, learning] didn't exist at install time — and even a successful base install lacked pipecat/twilio, so apps.api.main (which imports the streaming voice router) could not start in-container. Verified: both images build; `import apps.api.main` (full pipecat + Smart Turn chain) and PipecatProvider import succeed in-container. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
voice-smoke.yml was a fully disabled stub (if: false). Renamed to ci.yml: - unit job runs on every push/PR with no secrets (installs .[voice,dev] since the unit suite imports apps.voice.streaming) - smoke job (live STT/LLM/TTS, ~$0.06/run) unchanged but now reachable via manual dispatch, gated on unit passing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds Twilio webhook and Media Streams authentication, voice-call metrics and cost logging, validated provider secrets, voice dependency installation, CI changes, prompt ordering correction, and optional offer expiry formatting with focused tests and simulator updates. ChangesVoice platform changes
Data and prompt correctness
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Twilio
participant API
participant Streaming
participant Metrics
participant CostMeter
Twilio->>API: Send signed webhook
API->>API: Validate X-Twilio-Signature
API-->>Twilio: Return tokenized TwiML
Twilio->>Streaming: Connect with session token
Streaming->>Streaming: Verify stream token
Streaming->>Metrics: Emit pipeline metrics
Streaming->>Metrics: Finalize on teardown
Metrics->>CostMeter: Record LLM, STT, and TTS usage
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces robust Twilio request authentication for webhook and streaming endpoints, adds a comprehensive latency and cost tracking system for streaming voice calls via a new Pipecat metrics observer, and fixes several minor bugs including numerical prompt version sorting and environment variable resolution in CostMeter. The review feedback identifies three key areas for improvement: defensively handling unconfigured Twilio auth tokens in stream_token to avoid an AttributeError, protecting the metrics _stats helper against empty lists to prevent ZeroDivisionError and IndexError, and raising an HTTPException instead of closing the WebSocket to properly return an HTTP 403 status code during handshake rejection.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| 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] |
There was a problem hiding this comment.
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.
| 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 _stats(values: list[float]) -> dict: | ||
| ordered = sorted(values) | ||
| p95_index = max(0, round(0.95 * (len(ordered) - 1))) | ||
| return { | ||
| "avg": round(sum(ordered) / len(ordered), 4), | ||
| "p95": round(ordered[p95_index], 4), | ||
| "max": round(ordered[-1], 4), | ||
| "n": len(ordered), | ||
| } |
There was a problem hiding this comment.
If values is empty (e.g., if a processor did not record any metrics due to an early call termination or error), len(ordered) will be 0, leading to a ZeroDivisionError when calculating the average, and an IndexError when accessing ordered[p95_index]. Adding a defensive check for empty values ensures the observer finalizes successfully in all edge cases.
| def _stats(values: list[float]) -> dict: | |
| ordered = sorted(values) | |
| p95_index = max(0, round(0.95 * (len(ordered) - 1))) | |
| return { | |
| "avg": round(sum(ordered) / len(ordered), 4), | |
| "p95": round(ordered[p95_index], 4), | |
| "max": round(ordered[-1], 4), | |
| "n": len(ordered), | |
| } | |
| def _stats(values: list[float]) -> dict: | |
| if not values: | |
| return {"avg": 0.0, "p95": 0.0, "max": 0.0, "n": 0} | |
| ordered = sorted(values) | |
| p95_index = max(0, round(0.95 * (len(ordered) - 1))) | |
| return { | |
| "avg": round(sum(ordered) / len(ordered), 4), | |
| "p95": round(ordered[p95_index], 4), | |
| "max": round(ordered[-1], 4), | |
| "n": len(ordered), | |
| } |
| # Pre-accept close rejects the handshake (HTTP 403) before any | ||
| # pipeline resources are constructed. | ||
| logger.warning("Streaming WS rejected for session_id=%s: bad token", session_id) | ||
| await websocket.close(code=1008) | ||
| return |
There was a problem hiding this comment.
Calling websocket.close(code=1008) on an unaccepted WebSocket connection does not return a proper HTTP 403 Forbidden response during the handshake. In FastAPI, raising an HTTPException(status_code=403) before calling await websocket.accept() is the standard and clean way to reject the handshake with a 403 status code, preventing any pipeline resources from being constructed.
| # Pre-accept close rejects the handshake (HTTP 403) before any | |
| # pipeline resources are constructed. | |
| logger.warning("Streaming WS rejected for session_id=%s: bad token", session_id) | |
| await websocket.close(code=1008) | |
| return | |
| logger.warning("Streaming WS rejected for session_id=%s: bad token", session_id) | |
| from fastapi import HTTPException | |
| raise HTTPException(status_code=403, detail="Invalid stream token") |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with 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.
Inline comments:
In @.github/workflows/ci.yml:
- 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.
In `@agents/cost_meter.py`:
- Around line 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.
In `@apps/api/twilio_security.py`:
- Around line 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.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 985d2643-c252-4de0-a93d-546c2eb9e1e7
📒 Files selected for processing (21)
.env.example.github/workflows/ci.ymlDockerfile.apiDockerfile.workeragents/cost_meter.pyagents/prompt_loader.pyagents/schemas.pyapps/api/twilio_security.pyapps/api/voice_twiml.pyapps/voice/call_metrics.pyapps/voice/pipecat_provider.pyapps/voice/streaming.pyscripts/place_streaming_voice_call.pyscripts/simulate_twilio_stream.pyscripts/simulate_twiml_call.pytests/test_call_metrics_observer.pytests/test_cost_meter.pytests/test_pipecat_provider_secrets.pytests/test_prompt_loader.pytests/test_schemas.pytests/test_twilio_security.py
| timeout-minutes: 15 | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 |
There was a problem hiding this comment.
🔒 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.
| - 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
|
|
||
| # 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, |
There was a problem hiding this comment.
🎯 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:
- 1: https://deepgram.com/learn/best-speech-to-text-apis-2026
- 2: https://brasstranscripts.com/blog/deepgram-pricing-per-minute-2025-real-time-vs-batch
- 3: https://smallest.ai/blog/deepgram-pricing-plans-cost-what-you-get-in-2026
- 4: https://costbench.com/software/ai-transcription-apis/deepgram/
- 5: https://deepgram.com/pricing
- 6: https://developers.deepgram.com/docs/model
🌐 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:
- 1: https://rime.ai/resources/new-pricing
- 2: https://aiwarehub.com/tool/rime-ai-tts
- 3: https://rime.ai/resources/introducing-new-pricing
- 4: https://costbench.com/software/voice-apis/rime/
- 5: https://rime.ai/pricing
🌐 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 -nRepository: teetangh/defaultline
Length of output: 5463
Update the voice pricing constants in agents/cost_meter.py:45-49
deepgram/nova-3-generalshould be0.0078per minute.rime/mistv2should use0.05 / 1000per character;0.03 / 1000is 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.
| 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 verify_stream_token(session_id: str, token: str) -> bool: | ||
| return hmac.compare_digest(stream_token(session_id), token or "") |
There was a problem hiding this comment.
🩺 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 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.
P0 production-hardening pass from the production-readiness roadmap. No behavior changes to the conversation flow — this PR closes the gaps that block everything else: unauthenticated Twilio surface, images that can't run the voice path, and a metrics stream that was generated and dropped.
What's in here (one commit per item)
fix(bugs)— natural prompt-version sort (v10 ordered before v2),Offer.short()no longer renders/Noned, stale OpenRouter/MarkerStripper docstrings corrected.fix(secrets)—pipecat_providernow reads config via theSecretssettings object with actionable errors instead of bareos.environ[...]KeyErrors, validated before the heavyweight pipecat imports..env.examplegains the missingGROQ_API_KEYplusTWILIO_WEBHOOK_AUTH/WEBHOOK_BASE_URLdocs.feat(security)— every Twilio-called webhook validatesX-Twilio-Signature(against the publicWEBHOOK_BASE_URLTwilio actually signed); the Media Streams WS rejects connections pre-accept without a valid stateless HMAC token carried on<Stream url>.TWILIO_WEBHOOK_AUTH=auto|on|offkeeps dev/CI hermetic; both simulators work under enforcement (the TwiML simulator signs its requests, the streaming simulator uses thews_tokenfrom dial-init). Operator endpoints (dial-init,state) intentionally stay open — operator auth is a follow-up.feat(metrics)— newVoiceCallMetricsObserverconsumes the Pipecat MetricsFrames the pipeline already generates: per-stage TTFB/processing stats, LLM tokens, TTS chars → per-call summary indata/eval_runs/voice_call_metrics.jsonl+voice_llm/voice_stt/voice_ttsrows in the CostMeter ledger. Also fixesCOST_LOGbeing frozen at import time. This is the measurement baseline for the latency issues (Latency: config-driven TTS — Rime WebSocket (interim) → Cartesia Sonic (end state) #1, Latency: config-driven voice LLM — Groq Llama-3.3-70B / Claude Haiku 4.5 with tool-call re-validation #2, Latency: prompt diet + prompt caching + turn-taking tuning + pre-synthesized opener #3).fix(docker)— both images previously failed to build (sources copied afteruv pip install ., which hatchling needs present) and lacked voice extras. Verified: images build,import apps.api.main(full pipecat + Smart Turn chain) andPipecatProviderimport succeed in-container.ci—voice-smoke.yml(fully disabled stub) →ci.yml: unit job on every push/PR with no secrets; live smokes (~$0.06/run) reachable via manual dispatch, gated on unit.Verification
pytest -q: 123 passed (30 new tests: signature validation matrix, WS token accept/reject, metrics observer dedup + cost math, CostMeter regression, prompt-loader sort, secrets errors).TWILIO_WEBHOOK_AUTH=on→ unsigned webhook 403, operator dial-init open and returningws_token;off→ unsigned webhook passes the gate (404 unknown session).docker build+ in-container smoke imports for both images.Related issues
Unblocks #1, #2, #3 (latency measurement), #5, #6, #7, #8 (metrics/cost plumbing). Roadmap:
docs/architecture/10-production-readiness.md.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Chores