diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index e73ddf02..a1afcf6f 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -87,6 +87,13 @@ jobs: bandit: name: Python static analysis (bandit) runs-on: ubuntu-latest + # `security-events: write` is required by `codeql-action/upload-sarif` + # to push findings into the GitHub Security tab. Without it the upload + # step fails with "Resource not accessible by integration". `contents: + # read` is the minimum the checkout step needs. + permissions: + contents: read + security-events: write steps: - uses: actions/checkout@v6 - name: Set up Python 3.12 @@ -112,7 +119,7 @@ jobs: bandit -r libraries/python/getpatter -ll -iii \ --exclude libraries/python/getpatter/dashboard/ui.py \ -f sarif -o bandit.sarif || true - - uses: github/codeql-action/upload-sarif@v3 + - uses: github/codeql-action/upload-sarif@v4 # Only upload when the SARIF file was actually produced — if the # formatter install fails on a future bandit version the step # shouldn't fail the job, it just skips the Security-tab upload. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7310d9ae..1239b4cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,254 @@ ## Unreleased +## 0.6.2 (2026-05-25) + +### Added + +- **`OpenAIRealtime2` / `OpenAIRealtime2Adapter` — Python GA Realtime API + adapter (parity with TypeScript `OpenAIRealtime2` / `OpenAIRealtime2Adapter` + in `libraries/typescript/src/engines/openai-2.ts` / + `libraries/typescript/src/providers/openai-realtime-2.ts`).** The GA + endpoint rejects the legacy `OpenAI-Beta: realtime=v1` header and speaks a + different `session.update` wire shape (`output_modalities`, nested + `audio.{input,output}` with MIME type strings, `session.type = "realtime"`). + `OpenAIRealtime2Adapter` (in + `libraries/python/getpatter/providers/openai_realtime_2.py`) subclasses + `OpenAIRealtimeAdapter` and overrides `connect()`, `send_audio()`, + `receive_events()`, and `send_first_message()` to speak the GA wire shape + and perform bidirectional transcoding (mulaw 8 kHz ↔ PCM 24 kHz) required + because the GA audio engine silently drops mulaw frames. `OpenAIRealtime2` + engine marker (in `libraries/python/getpatter/engines/openai_realtime_2.py`) + defaults to `gpt-realtime-2`. Both are exported from the top-level package: + `from getpatter import OpenAIRealtime2, OpenAIRealtime2Adapter`. Wire up via + `phone.agent(engine=OpenAIRealtime2(reasoning_effort="low"), ...)`. + +### Changed + +- **`OpenAIRealtime` default model changed from `gpt-4o-mini-realtime-preview` + to `gpt-realtime-mini`** in + `libraries/python/getpatter/engines/openai.py` and the `agent()` sentinel + in `libraries/python/getpatter/client.py`. The beta + `gpt-4o-mini-realtime-preview` model is deprecated on the GA endpoint as of + 2026-05. `gpt-realtime-mini` is the equivalent GA model. Existing callers + that do not pin a model are automatically upgraded; callers that explicitly + pass `model="gpt-4o-mini-realtime-preview"` should migrate to + `model="gpt-realtime-mini"` or switch to `OpenAIRealtime2`. + +- **`phone.ready` and `phone.tunnel_ready` — serve-ready awaitables for + outbound call orchestration (Python parity with TypeScript).** Both + SDKs have always exposed these futures on the `Patter` class, but the + Python docs showed the `asyncio.sleep(2)` anti-pattern instead of the + correct `await phone.ready` pattern. Updated `docs/python-sdk/local-mode.mdx` + to replace the `asyncio.sleep` example with `await phone.ready`, document + the reject-on-failure guarantee, and add a note on `await phone.tunnel_ready` + for hostname-only use cases. Added 15 unit tests covering lazy creation, + idempotent access, resolution, rejection, idempotent resolve/reject guards, + static-webhook pre-resolution, and post-`disconnect()` future recreation — + mirroring the TS `client.test.ts` ready/tunnelReady coverage. + +### Fixed + +- **TypeScript `TwilioAdapter.generateStreamTwiml` now accepts an optional + `parameters` argument (parity with Python `generate_stream_twiml`).** The + static method previously ignored caller/callee context — passing + `parameters: Record` now emits + `` children of ``, which is the + only reliable path for pre-populating `start.customParameters` on the WS + `start` frame (Twilio strips query-string params from the `` + before the WebSocket handshake). The inbound webhook path in `server.ts` + already inlined this TwiML directly; `generateStreamTwiml` is now brought + into full API-surface parity so callers who construct TwiML via the adapter + get the same behaviour. File: `libraries/typescript/src/providers/twilio-adapter.ts`. + +- **Python outbound Twilio calls crashed with `TypeError: unexpected + keyword argument 'StatusCallback'` (and similar for `Timeout`, + `MachineDetection`, `AsyncAmd`).** `libraries/python/getpatter/client.py` + was building the `extra_params` dict with PascalCase keys matching + Twilio's REST wire protocol, but `twilio-python`'s + `Client.calls.create(**kwargs)` only accepts snake_case — it + translates internally to PascalCase before hitting the wire. Every + outbound call using machine detection, `ring_timeout`, or status + callbacks crashed at the SDK boundary (reported externally on + zenn.dev for SDK 0.5.4). Fixed at source: all keys in `extra_params` + are now snake_case (`status_callback`, `machine_detection`, + `timeout`, `async_amd`, `async_amd_status_callback`, + `status_callback_method`). Added a defensive PascalCase → + snake_case normalisation pass in + `libraries/python/getpatter/providers/twilio_adapter.py` so any + future caller passing the wire-protocol spelling is auto-corrected + before reaching the SDK. TypeScript SDK is unaffected — it sends raw + `URLSearchParams` directly to Twilio's REST endpoint where + PascalCase is the correct on-wire form. Regression locked in by + `libraries/python/tests/unit/test_twilio_adapter_snake_case_kwargs.py`. + +- **Phantom barge-in: cellular noise within 100 ms post-pickup was + triggering self-cancellation of the prewarmed greeting.** Bumped + `MIN_AGENT_SPEAKING_MS_BEFORE_BARGE_IN_NO_AEC` from 100 ms → 500 ms + in `libraries/typescript/src/stream-handler.ts` and + `libraries/python/getpatter/stream_handler.py`. The 100 ms window was + too tight — Twilio's media stream can emit background carrier noise + (clicks, handshake tones, audio codec initialization) within the first + 100 ms after pickup, which the VAD read as speech-like energy and + triggered a barge-in cancel. Extending to 500 ms allows the carrier + audio path to stabilise before the agent's greeting becomes cancelable. + +- **VAD telephony preset too sensitive: background room voices tripping + barge-in.** `SileroVAD.forPhoneCall()` factory (TS) / + `SileroVAD.for_phone_call` (Py) now raises activation threshold 0.5 → + 0.8 and deactivation threshold 0.35 → 0.65. The Silero model's + upstream defaults (0.5 / 0.35) are tuned for studio audio; when + running on 8 kHz telephony-band upsampled to 16 kHz, non-speech room + noise (HVAC, background chatter, line buzz) was accumulating energy + above the 0.5 threshold. Real-call acceptance testing showed natural + pauses in the user's speech no longer trigger false barge-ins at the + higher thresholds. Files: `libraries/typescript/src/providers/ + silero-vad.ts`, `libraries/python/getpatter/providers/silero_vad.py`. + +- **`prewarmFirstMessage` default reverted to `false`.** An earlier + 0.6.2 attempt defaulted the flag to `true` in the factory; this + proved incompatible with the above barge-in fixes. When the greeting + is prewarmed but the phantom-barge-in (or VAD sensitivity) fires + incorrectly on carrier-side noise, the agent cancels the cached + audio without having spoken a character, leaving the caller in silence + for 1–2 s while the agent recovers from the false cancel-and-restart + cycle. Reverting to `prewarmFirstMessage: false` (TS) / + `prewarm_first_message=False` (Py) at the factory level in + `libraries/typescript/src/client.ts:Patter.agent()` and + `libraries/python/getpatter/client.py:Patter.agent()`. Users who + *want* the latency reduction should opt in explicitly: `phone.agent({ + prewarmFirstMessage: true })` — recommended for inbound calls and + low-noise deployments. Realtime / ConvAI modes unaffected. + +- **ElevenLabs HTTP TTS now auto-detects carrier and sets + `outputFormat`.** Added `setTelephonyCarrier(carrierHint: string)` + method to `ElevenLabsTTS` (TS) / `ElevenLabsTTS.set_telephony_carrier` + (Py). When constructing `ElevenLabsTTS()` without an explicit + `outputFormat` on Twilio, the factory `ElevenLabsTTS.forTwilio()` + calls `setTelephonyCarrier("twilio")` to flip `outputFormat` to + `"ulaw_8000"`, eliminating the per-frame resample + mulaw encode + overhead. The plain constructor now only forwards `outputFormat` when + the caller passed one explicitly — was unconditionally forwarding a + `"pcm_16000"` fallback that disabled the carrier auto-flip logic. + This matches the existing `ElevenLabsWebSocketTTS` carrier-aware + behaviour. Files: `libraries/typescript/src/providers/elevenlabs-tts.ts`, + `libraries/python/getpatter/providers/elevenlabs_tts.py`. + +- **ElevenLabs WebSocket TTS now exposes `cancelActiveStream()` for + barge-in cleanup.** The WebSocket variant held a live `activeStreamWs` + reference but had no public way to abort it. `StreamHandler.cancelSpeaking` + / `handleStop` / `handleWsClose` now call `tts.cancelActiveStream()`, + unblocking the synthesizeStream generator's inner `await Promise` + loop immediately when the carrier ends the call or the user barges in. + Root cause of the post-hangup 30 s timeout error logs and stale token + billing. Files: `libraries/typescript/src/providers/elevenlabs-ws-tts.ts`, + `libraries/python/getpatter/providers/elevenlabs_ws_tts.py`. + +- **Wrapper class TTS `outputFormat` field now conditional.** When an + `ElevenLabsTTS` or `ElevenLabsWebSocketTTS` wrapper receives a carrier + hint (e.g. Twilio), the wrapper's `outputFormat` field is set only if + the caller passed it explicitly. Previous logic always forwarded a + fallback value, which caused the carrier auto-flip to treat + `outputFormat` as explicit and skip the optimization. Now the carrier + auto-flip logic runs correctly: if no `outputFormat` was passed, the + wrapper field remains `undefined`/`None` and the carrier-specific Twilio + path activates naturally. Files: `libraries/typescript/src/tts/elevenlabs.ts`, + `libraries/typescript/src/tts/elevenlabs-ws.ts`, + `libraries/python/getpatter/tts/elevenlabs.py`. + +- **`sendPacedFirstMessageBytes` timing rewritten: burst mode, no per-chunk + sleep.** The original implementation paced each prewarm chunk with a + `setTimeout` / `asyncio.sleep` of one chunk-equivalent of playout time + (~40 ms for the 1280-byte default chunk). Combined with the + `waitForMarkWindow` back-pressure await and JavaScript/asyncio timer + jitter, effective delivery dropped BELOW Twilio's 8 kHz playout clock, + producing repeated carrier-side underruns. Caller heard "slow, gravelly, + and arriving more slowly than the rest". Twilio's docs (Media Streams → + WebSocket Messages) state "media messages of any size" are "buffered + and played in the order received" by the carrier-side media server — the + carrier owns the playout clock. Rewrote to burst all prewarm chunks + back-to-back with 20 ms frame granularity (no per-chunk sleep), matching + the live-TTS streaming path that always worked. Per-chunk marks still + emitted for fine-grained barge-in cut. Files: `libraries/typescript/src/ + stream-handler.ts`, `libraries/python/getpatter/stream_handler.py`. + +- **Mulaw native fast path in audio encode: skip resample + encode when + TTS outputs `ulaw_8000` natively.** When pipeline mode detects + `tts.outputFormat === "ulaw_8000"` on Twilio, `encodePipelineAudio` + skips the resample (16 kHz → 8 kHz) + mulaw encode chain entirely and + base64-encodes the raw bytes. Probed once in `initPipeline` and cached + as `ttsOutputFormatNativeForCarrier`. Saves ~1–2 ms per 20 ms frame, + cumulative ~5–10 % CPU when deployed at scale. Files: + `libraries/typescript/src/stream-handler.ts`, `libraries/python/ + getpatter/stream_handler.py`. + +- **`handleStop` / `handleWsClose` now abort in-flight LLM and cancel TTS + immediately.** When the carrier ends a call or the StreamHandler is torn + down, both paths now call `llmAbort()` (to unblock any pending LLM stream) + and `tts.cancelActiveStream()` (to unblock any pending TTS stream). + Prevents stale token billing and 30 s timeout error logs from post-hangup + tasks trying to drain a closed WebSocket. Files: `libraries/typescript/src/ + stream-handler.ts`, `libraries/python/getpatter/stream_handler.py`. + +- **Python SDK parity sync for 2026-05-20 acceptance session.** All TS + fixes landed during PSTN acceptance testing are now ported to Python: + `ElevenLabsTTS.set_telephony_carrier` (HTTP variant, mirrors WS), + `ElevenLabsWebSocketTTS.cancel_active_stream` + `_active_stream_ws` + tracking, `_do_cancel_for_barge_in` / `cleanup` calling + `cancel_active_stream` (duck-typed), `_is_tts_output_format_native_for_carrier` + probe + `_tts_output_format_native_for_carrier` flag + audio-sender bypass + in `PipelineStreamHandler.start`, `_spawn_prewarm_first_message` accepting + `carrier=` and calling `set_telephony_carrier` before synthesis, and the + `tts/elevenlabs.py` wrapper only forwarding `output_format` when explicitly + passed. Files: `libraries/python/getpatter/providers/elevenlabs_tts.py`, + `libraries/python/getpatter/providers/elevenlabs_ws_tts.py`, + `libraries/python/getpatter/stream_handler.py`, + `libraries/python/getpatter/tts/elevenlabs.py`, + `libraries/python/getpatter/client.py`. + +- **Bidirectional race guard on `recordTurnComplete` / `recordTurnInterrupted`.** + The original guard (added earlier in this release) was one-directional: + a late `recordTurnComplete` after `recordTurnInterrupted` was dropped, + but the inverse ordering (a late interrupt after a completed turn) + could still overwrite a just-emitted turn record. The current caller + paths can't produce that ordering, but the symmetric guard hardens + the accumulator against future refactors. Both `recordTurnComplete` + and `recordTurnInterrupted` now set `_turnAlreadyClosed`/` + _turn_already_closed` and check it on entry. Same fix in + `libraries/python/getpatter/services/metrics.py` and + `libraries/typescript/src/metrics.ts`; regression tests added in both + suites. + +### Fixed + +- **Pipeline metrics: `transcript.jsonl` rows after a barge-in carried an + empty `user_text` even when the user had clearly spoken.** Root cause + was a race between the two turn-close paths: a VAD-driven barge-in + fired `record_turn_interrupted` / `recordTurnInterrupted` synchronously + inside the audio handler and `_reset_turn_state` cleared + `_turn_user_text`, while the in-flight pipeline LLM stream kept + unwinding on its own task and eventually reached + `record_turn_complete` / `recordTurnComplete` — which then pushed a + second turn for the same logical exchange carrying `user_text=""`. + Both SDKs now flip a `_turn_already_closed` / `_turnAlreadyClosed` + guard on `record_turn_interrupted` and have `record_turn_complete` + return `None` / `null` until the next `start_turn` re-arms the + accumulator. `_emit_turn_metrics` / `emitTurnMetrics` were already + null-safe, so the late call becomes a silent no-op end-to-end. + Regression tests pinning the bargein → llmAbort → late-complete + ordering live in `libraries/python/tests/test_metrics.py` and + `libraries/typescript/tests/metrics.test.ts`. See + `patter-sdk-acceptance/BUGS.md` (2026-05-05 entry). + +- **CI: Security Audit workflow could not upload Bandit SARIF to the GitHub + Security tab.** The `bandit` job in `.github/workflows/audit.yml` was + failing on `github/codeql-action/upload-sarif` with `Resource not + accessible by integration` because the job inherited the repo-default + read-only `GITHUB_TOKEN` permissions. Added an explicit + `permissions: { contents: read, security-events: write }` block on the + job so SARIF findings reach the Security tab as intended. Bumped the + action from `@v3` to `@v4` to drop the deprecation warning ahead of the + December 2026 sunset. + ## 0.6.1 (2026-05-15) ### Fixed — `OpenAIRealtime2`: audio transcoding for Twilio + outbound chunking + VAD tuning (TypeScript only) diff --git a/README.md b/README.md index dbb51759..9cfe96cc 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ await phone.serve({ agent, tunnel: true }); -`tunnel: true` spawns a Cloudflare tunnel and points your Twilio number at it. In production, pass `webhook_url` / `webhookUrl` to the constructor instead. Every carrier and provider reads its credentials from environment variables by default; see each SDK's README for the full catalog. +`tunnel: true` spawns a Cloudflare quick tunnel and points your Twilio number at it — great for dev / acceptance. For production outbound calls (especially on Twilio), replace it with [ngrok](https://ngrok.com) or a static `webhook_url` to avoid WSS upgrade races on first call. See [Tunneling](/docs/dev-tools/tunneling) for details. + +Every carrier and provider reads its credentials from environment variables by default; see each SDK's README for the full catalog. ## How Patter compares diff --git a/dashboard-app/src/App.tsx b/dashboard-app/src/App.tsx index dd21ea6e..2564ff80 100644 --- a/dashboard-app/src/App.tsx +++ b/dashboard-app/src/App.tsx @@ -18,7 +18,11 @@ import { type SparklineResult, } from './lib/mappers'; -const SDK_VERSION = '0.6.0'; +// Fallback when the server-side ``aggregates.sdk_version`` field is missing +// (older backend, transient fetch error). Both SDKs (Python + TS) now ship +// the live ``getpatter.__version__`` / ``package.json#version`` in every +// ``/api/dashboard/aggregates`` response. +const SDK_VERSION_FALLBACK = 'dev'; const RANGE_LABEL: Record = { '1h': '1h', '24h': '24h', @@ -128,6 +132,12 @@ export function App() { const rangeAvgP95 = avgP95(filteredCalls) || aggregates?.avg_latency_ms || 0; const rangeSpend = totalSpend(filteredCalls) || aggregates?.total_cost || 0; const phoneNumber = pickPhoneNumber(calls); + // Server-derived SDK version (single source of truth: ``getpatter.__version__`` + // in Python / ``package.json#version`` in TS, surfaced via the aggregates + // payload). Falls back when the server side is older than this SPA build. + const sdkVersion = + (typeof aggregates?.sdk_version === 'string' && aggregates.sdk_version) || + SDK_VERSION_FALLBACK; const sparkTotalCalls = useMemo( () => computeSparkline(filteredCalls, 'totalCalls', strategy), @@ -183,7 +193,7 @@ export function App() { liveCount={liveCount} todayCount={totalCount} phoneNumber={phoneNumber} - sdkVersion={SDK_VERSION} + sdkVersion={sdkVersion} revealed={revealed} dark={dark} onToggleRevealed={toggleRevealed} @@ -262,7 +272,7 @@ export function App() { {isStreaming ? 'streaming · sse' : error ? `error · ${error}` : 'idle'} - SDK · {SDK_VERSION} + SDK · {sdkVersion}
diff --git a/dashboard-app/src/lib/api.ts b/dashboard-app/src/lib/api.ts index b5e79f94..edee21fe 100644 --- a/dashboard-app/src/lib/api.ts +++ b/dashboard-app/src/lib/api.ts @@ -74,6 +74,12 @@ export interface Aggregates { readonly avg_latency_ms: number; readonly cost_breakdown: CostBreakdown; readonly active_calls: number; + /** + * SDK version reported by the server (auto-derived from + * ``getpatter.__version__`` in Python / ``package.json#version`` in TS). + * Optional — older backends omit it; the SPA falls back gracefully. + */ + readonly sdk_version?: string; } const isObject = (value: unknown): value is Record => @@ -204,6 +210,7 @@ function parseAggregates(raw: unknown): Aggregates { active_calls: 0, }; } + const sdkVersion = asString(raw.sdk_version); return { total_calls: asNumber(raw.total_calls), total_cost: asNumber(raw.total_cost), @@ -211,6 +218,7 @@ function parseAggregates(raw: unknown): Aggregates { avg_latency_ms: asNumber(raw.avg_latency_ms), cost_breakdown: parseCostBreakdown(raw.cost_breakdown), active_calls: asNumber(raw.active_calls), + ...(sdkVersion ? { sdk_version: sdkVersion } : {}), }; } diff --git a/docs/dev-tools/tunneling.mdx b/docs/dev-tools/tunneling.mdx index 851fb066..4c37f817 100644 --- a/docs/dev-tools/tunneling.mdx +++ b/docs/dev-tools/tunneling.mdx @@ -8,9 +8,11 @@ icon: "cloud" Patter needs a public URL so your telephony provider can send webhooks to the local server. There are three options, all configured via the `tunnel=` argument on `Patter()` (or `new Patter({ tunnel })`). -## CloudflareTunnel (recommended) +## CloudflareTunnel (dev / acceptance only) -The built-in Cloudflare Quick Tunnel creates a public `*.trycloudflare.com` URL. No account or setup required — just the `cloudflared` binary on `PATH` (or the `cloudflared` npm package). +The built-in Cloudflare Quick Tunnel creates a public `*.trycloudflare.com` URL with zero setup. No account required — just the `cloudflared` binary on `PATH` (or the `cloudflared` npm package). + +**Warning (Twilio outbound calls):** Cloudflare Quick Tunnel routes traffic through a different edge pool than Twilio's Media Streams WebSocket upgrade path. On **first outbound call**, the WSS upgrade can race and drop ~1 % of calls. For production Twilio outbound use, replace this with [ngrok](#static-user-managed-tunnel) or a pre-provisioned named Cloudflare tunnel instead. ```python Python @@ -99,7 +101,9 @@ If auto-configuration fails (for example the Twilio auth token doesn't have perm ## Static (user-managed tunnel) -Running ngrok or some other public hostname yourself? Use `Static(hostname=...)` — Patter will skip process management and just trust the hostname you provide. +For production, especially with Twilio outbound calls, manage your own tunnel using ngrok or a pre-provisioned Cloudflare Named Tunnel. Patter will skip process management and trust the hostname you provide. + +**Recommended for Twilio outbound:** ngrok stabilizes the WSS upgrade path and eliminates the Quick Tunnel race. Sign up free at [ngrok.com](https://ngrok.com), then: ```bash ngrok http 8000 diff --git a/docs/docs.json b/docs/docs.json index 55b9152e..1346aa31 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -100,6 +100,7 @@ "pages": [ "python-sdk/engines", "python-sdk/providers/openai-realtime", + "python-sdk/providers/openai-realtime-2", "python-sdk/providers/gemini-live", "python-sdk/providers/ultravox-realtime", "python-sdk/providers/elevenlabs-convai" @@ -231,6 +232,7 @@ "pages": [ "typescript-sdk/engines", "typescript-sdk/providers/openai-realtime", + "typescript-sdk/providers/openai-realtime-2", "typescript-sdk/providers/gemini-live", "typescript-sdk/providers/ultravox-realtime", "typescript-sdk/providers/elevenlabs-convai" diff --git a/docs/github-banner.png b/docs/github-banner.png index 866f3f6d..75fe96ac 100644 Binary files a/docs/github-banner.png and b/docs/github-banner.png differ diff --git a/docs/python-sdk/agents.mdx b/docs/python-sdk/agents.mdx index 26df9d32..adeb225c 100644 --- a/docs/python-sdk/agents.mdx +++ b/docs/python-sdk/agents.mdx @@ -70,12 +70,12 @@ agent = phone.agent( | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `system_prompt` | `str` | *required* | Instructions that define the agent's behavior. | -| `engine` | `OpenAIRealtime \| ElevenLabsConvAI \| None` | `None` → OpenAI Realtime | End-to-end engine. See [Engines](/python-sdk/engines). Omit for pipeline mode. | +| `engine` | `OpenAIRealtime \| OpenAIRealtime2 \| ElevenLabsConvAI \| None` | `None` → OpenAI Realtime | End-to-end engine. See [Engines](/python-sdk/engines). Omit for pipeline mode. | | `stt` | `STTProvider \| None` | `None` | STT instance for pipeline mode (`DeepgramSTT()`, `CartesiaSTT()`, ...). See [STT](/python-sdk/stt). | | `llm` | `LLMProvider \| None` | `None` | LLM instance for pipeline mode (`AnthropicLLM()`, `GroqLLM()`, ...). Mutually exclusive with `on_message` on `serve()`. Ignored when `engine` is set. See [LLM](/python-sdk/llm). | | `tts` | `TTSProvider \| None` | `None` | TTS instance for pipeline mode (`ElevenLabsTTS()`, `RimeTTS()`, ...). See [TTS](/python-sdk/tts). | | `voice` | `str` | `"alloy"` | Voice name. Usually inferred from the engine or TTS instance. | -| `model` | `str` | `"gpt-4o-mini-realtime-preview"` | Model ID for OpenAI Realtime. Usually inferred from the engine. | +| `model` | `str` | `"gpt-realtime-mini"` | Model ID for OpenAI Realtime. Usually inferred from the engine. | | `language` | `str` | `"en"` | BCP-47 language code. | | `first_message` | `str` | `""` | If set, the agent speaks this immediately when a call connects. | | `tools` | `list[Tool] \| None` | `None` | `Tool(...)` instances for function calling. See [Tools](/python-sdk/tools). | @@ -89,6 +89,7 @@ agent = phone.agent( | `barge_in_threshold_ms` | `int` | `300` | Sustained-voice window (ms) before treating caller audio as barge-in. Set to `0` to disable. | | `aggressive_first_flush` | `bool` | `False` | Opt-in low-latency mode: emits the first clause on a soft punctuation boundary (`,`, em-dash, en-dash) once the buffer reaches ~40 chars. Saves 200–500 ms TTFA on the first sentence at the cost of slightly clipped prosody. **Hard-disabled when `language` starts with `"it"`** (Italian decimal commas would split mid-number). Pipeline mode only. | | `disable_phone_preamble` | `bool` | `False` | When `False` (default), Patter prepends a phone-friendly preamble to `system_prompt` that instructs the LLM to avoid markdown, emojis, bullet lists, and code blocks; spell out numbers and dates; and keep replies short. Set to `True` to ship `system_prompt` verbatim. | +| `prewarm_first_message` | `bool` | `False` | Pre-render `first_message` to TTS audio bytes during the ringing window and stream the cached buffer the instant the call connects, eliminating the 200–700 ms TTS first-byte latency on the greeting. Pipeline mode only — the flag is silently ignored (with a `WARN` log) on Realtime / ConvAI engines. Trade-off: pays for the greeting's TTS even when the call rings out unanswered (~$0.001–$0.005 per ring). Opt in explicitly for inbound calls and low-noise deployments: `prewarm_first_message=True`. | ## Agent Dataclass @@ -153,13 +154,29 @@ agent = phone.agent( ) ``` +### Pre-warming the first message + +Pipeline-mode agents can pre-render the `first_message` audio during the ringing window and stream the cached buffer the instant the call connects — eliminating the 200–700 ms TTS first-byte latency on the greeting. Opt in explicitly: + +```python +agent = phone.agent( + system_prompt="...", + first_message="Hello!", + prewarm_first_message=True, # enable pre-rendering +) +``` + +The trade-off is paying for the greeting's TTS even when the call rings out unanswered (typically $0.001–$0.005 per ring depending on TTS provider). Good for inbound calls and low-noise deployments; disable for very high-volume outbound where un-answered TTS spend matters. + +Realtime / ConvAI engines don't consume the pre-rendered cache (their first message goes through the engine's own audio path); the flag is silently ignored with a `WARN` log when set on a non-pipeline agent. + ## Voice Selection Voice is usually inferred from the engine or TTS instance — e.g. `OpenAIRealtime(voice="nova")` or `ElevenLabsTTS(voice_id="rachel")`. Available voices depend on the provider. - `"alloy"`, `"echo"`, `"fable"`, `"onyx"`, `"nova"`, `"shimmer"` + `"alloy"`, `"ash"`, `"ballad"`, `"coral"`, `"echo"`, `"fable"`, `"nova"`, `"onyx"`, `"sage"`, `"shimmer"`, `"verse"` Any ElevenLabs voice ID or name (e.g., `"rachel"`, `"adam"`). @@ -189,12 +206,12 @@ agent = phone.agent( ) ``` -`SileroVAD.for_phone_call(**overrides)` is identical to `SileroVAD.load(...)` but pins `sample_rate` to 16 000 Hz — the only sample rate Patter's pipeline-mode audio bus uses (8 kHz mulaw from Twilio is upsampled to 16 kHz PCM before reaching the VAD). All other parameters use the upstream `snakers4/silero-vad` defaults: +`SileroVAD.for_phone_call(**overrides)` is identical to `SileroVAD.load(...)` but pins `sample_rate` to 16 000 Hz — the only sample rate Patter's pipeline-mode audio bus uses (8 kHz mulaw from Twilio is upsampled to 16 kHz PCM before reaching the VAD). Parameters are tuned for telephony-band audio (not the upstream Silero studio defaults): | Field | Default | Upstream equivalent | |-------|---------|---------------------| -| `activation_threshold` | `0.5` | `threshold` | -| `deactivation_threshold` | `0.35` | `neg_threshold = threshold − 0.15` | +| `activation_threshold` | `0.8` | `threshold` (tuned for telephony, not studio) | +| `deactivation_threshold` | `0.65` | `neg_threshold = threshold − 0.15` (tuned for telephony) | | `min_speech_duration` | `0.25` s | `min_speech_duration_ms = 250` | | `min_silence_duration` | `0.1` s | `min_silence_duration_ms = 100` | | `prefix_padding_duration` | `0.03` s | `speech_pad_ms = 30` | diff --git a/docs/python-sdk/call-logging.mdx b/docs/python-sdk/call-logging.mdx index 4a961b9e..cd38b6a3 100644 --- a/docs/python-sdk/call-logging.mdx +++ b/docs/python-sdk/call-logging.mdx @@ -4,7 +4,9 @@ description: "Opt-in per-call filesystem logs: metadata, transcripts, and operat icon: "folder-open" --- -Patter can persist every call to a directory tree on disk so you can replay transcripts, audit tool calls, and track latency/cost trends without running a hosted dashboard. Logging is **opt-in and off by default** — nothing is written unless you ask for it. +Patter can persist every call to a directory tree on disk so you can replay transcripts, audit tool calls, and track latency/cost trends without running a hosted dashboard. + +Persistence is **on by default since 0.6.2** — `Patter(...)` writes under the platform default location unless you pass `persist=False` (force-off) or override the path. The default was flipped from off→on because the dashboard's hydrate path needs on-disk records to survive process restarts. The same on-disk layout also backs the local dashboard's call history: when persistence is enabled, `phone.serve()` rebuilds the in-memory dashboard from disk on startup so call history survives process restarts without an external database. @@ -26,7 +28,7 @@ phone = Patter(carrier=Twilio(), phone_number="+15555550100", persist="/var/log/ | `persist` value | Behaviour | |-----------------|-----------| -| omitted / `None` (default) | Falls back to `PATTER_LOG_DIR`; off when env is also unset (backward-compatible). | +| omitted / `None` (default) | Reads `PATTER_LOG_DIR` if set; otherwise falls back to the platform default location. On by default since 0.6.2. | | `False` | Force-off, even if `PATTER_LOG_DIR` is set. | | `True` | Platform default location (see below). | | `""` | Use the supplied path (`~` expanded). | @@ -49,7 +51,7 @@ Platform defaults for `auto` (and for `persist=True`): - Linux: `$XDG_DATA_HOME/patter` (falls back to `~/.local/share/patter`) - Windows: `%LOCALAPPDATA%\patter` -When `persist` is unset and the env var is unset, the logger is a no-op — no directories are created, no files are written. When `persist` is set explicitly, the env var is ignored. +When `persist` is unset and the env var is unset, Patter falls back to the platform default path (persistence is on by default since 0.6.2). When `persist` is set explicitly, the env var is ignored. Pass `persist=False` to force the logger to be a no-op. ## Layout @@ -87,19 +89,21 @@ When `persist` is unset and the env var is unset, the logger is a no-op — no d ## Phone redaction -Caller / callee numbers in `metadata.json` are masked by default (last 4 digits). Change via: +Caller / callee numbers in `metadata.json` default to **full** (raw E.164) since 0.6.2 so the dashboard's reveal toggle has something to reconstruct from. The on-disk path under `~/Library/Application Support/patter/` (macOS) / XDG data dir (Linux) / `%LOCALAPPDATA%\patter` (Windows) is user-private. Override via: ```bash -# Mask last 4 digits (default): "***4567" -export PATTER_LOG_REDACT_PHONE=mask - -# Store the full E.164 number (disables redaction) +# Store the full E.164 number (default since 0.6.2) export PATTER_LOG_REDACT_PHONE=full +# Mask last 4 digits: "***4567" +export PATTER_LOG_REDACT_PHONE=mask + # Replace with a sha256 prefix for correlation without storing the number export PATTER_LOG_REDACT_PHONE=hash_only ``` +Set `PATTER_LOG_REDACT_PHONE=mask` for setups that ship logs off-host. + `transcript.jsonl` is **not** redacted — it can contain customer PII spoken during the call. Gate access to the log root and/or wire up your own redaction pipeline before exporting. ## Retention diff --git a/docs/python-sdk/carrier.mdx b/docs/python-sdk/carrier.mdx index 1a5fa8bc..37cd99ab 100644 --- a/docs/python-sdk/carrier.mdx +++ b/docs/python-sdk/carrier.mdx @@ -41,6 +41,16 @@ carrier = twilio.Carrier(account_sid="AC...", auth_token="...") On `serve()`, Patter automatically sets the `voice_url` on the Twilio number to `https:///webhooks/twilio/voice` via the Twilio REST API — no manual Console configuration needed. +### Twilio trial account limitations + +Twilio trial accounts apply a few platform-level restrictions that affect first-time testing. None of these are Patter limitations — they're Twilio platform rules: + +1. **Verified Caller IDs required for outbound** — trial accounts can only call numbers you've added under **Phone Numbers › Verified Caller IDs** in the Twilio Console. Verifying via the CLI is also restricted; do it from the Console. +2. **Trial announcement prepended on outbound calls** — Twilio plays an English trial-account message before connecting the call to your agent; the callee has to press a key to continue. +3. **Trial caller-ID restrictions** — the caller-ID shown to the recipient may be masked or labelled differently than your purchased number until the account is upgraded. + +Upgrading the account in the Twilio Console clears all three. Refer to the Twilio docs (search: "trial account") for the current exact behaviour — Twilio may change these over time. + ### Signature verification The Auth Token is also used to verify every Twilio webhook with HMAC-SHA1 against the `X-Twilio-Signature` header. Requests with invalid signatures are rejected with HTTP 403. @@ -92,6 +102,41 @@ The embedded server exposes these endpoints regardless of carrier choice: | `POST /webhooks/twilio/amd` | Async AMD (answering machine detection) results. | | `POST /webhooks/telnyx/voice` | Incoming Telnyx call → returns Call Control commands. | +## Outbound calls + +Use `phone.call(...)` to place an outbound call on either carrier. Every keyword argument is **snake_case**: + +```python +import asyncio +from getpatter import Patter, Twilio, OpenAIRealtime + +phone = Patter(carrier=Twilio(), phone_number="+15550001234") +agent = phone.agent(engine=OpenAIRealtime(), system_prompt="You are a friendly receptionist.") + +async def main(): + server = asyncio.create_task(phone.serve(agent, tunnel=True)) + await phone.ready # wait until tunnel + listener are up + + await phone.call( + to="+15550009876", + agent=agent, + first_message="Hi! This is a courtesy call from Acme.", + machine_detection=True, # default since 0.6.2 + ring_timeout=25, # default since 0.6.2 + voicemail_message="Please call us back at +15550001234.", + ) + +asyncio.run(main()) +``` + +Key defaults changed in 0.6.2: + +- `machine_detection` defaults to `True`. On Twilio Patter sends `MachineDetection=DetectMessageEnd` + Async AMD so there is no answer-latency penalty on human pickups. Pass `False` to skip per-call AMD billing. +- `ring_timeout` defaults to `25` seconds. Pass `60` for legacy carrier-default parity, or `None` to omit the parameter entirely. +- The AMD callback was renamed `on_machine` → `on_machine_detection` and now receives a `MachineDetectionResult` (not a raw dict). + +See [Local Mode › call() Parameters](/python-sdk/local-mode#call-parameters-local-mode) for the full parameter table. + ## What's Next diff --git a/docs/python-sdk/configuration.mdx b/docs/python-sdk/configuration.mdx index adf3db1f..722ba758 100644 --- a/docs/python-sdk/configuration.mdx +++ b/docs/python-sdk/configuration.mdx @@ -27,7 +27,7 @@ The carrier instance reads credentials from environment variables when you don't | `webhook_url` | `str` | `""` | Public hostname of this server, without scheme (e.g., `"abc.ngrok.io"`). See [Tunneling](/dev-tools/tunneling) for ways to get one. | | `tunnel` | `CloudflareTunnel \| Static \| bool \| None` | `None` | Tunnel directive. `True` is shorthand for `CloudflareTunnel()`. See [Tunneling](/dev-tools/tunneling). | | `pricing` | `dict \| None` | `None` | Override default provider pricing estimates. See [Metrics & Cost Tracking](/python-sdk/metrics). | -| `persist` | `bool \| str \| None` | `None` | Persist the dashboard's call history to disk so it survives process restarts. See [Persistent dashboard history](#persistent-dashboard-history) below. | +| `persist` | `bool \| str \| None` | `None` (=on, platform default path) | Persist the dashboard's call history to disk so it survives process restarts. Defaults **on** since 0.6.2 — the dashboard's hydrate path requires on-disk records to recover history across restarts. Pass `False` to keep the old ephemeral-RAM-only behaviour. See [Persistent dashboard history](#persistent-dashboard-history) below. | ## Environment variables @@ -55,7 +55,7 @@ These tune SDK runtime behaviour (no credential lookup). |---------|---------|--------| | `PATTER_LOG_DIR` | unset | Persistent dashboard root (see [Persistent dashboard history](#persistent-dashboard-history) below). | | `PATTER_LOG_RETENTION_DAYS` | `30` | Days of disk history to retain. `0` disables cleanup. | -| `PATTER_LOG_REDACT_PHONE` | `1` | Mask phone numbers in `metadata.json` (last 4 digits). Set to `0` to store full E.164. | +| `PATTER_LOG_REDACT_PHONE` | `full` | One of `full` (store raw E.164), `mask` (last 4 digits), or `hash_only` (sha256:prefix). Changed from `mask` to `full` on 2026-05-21 so the dashboard's reveal toggle has something to reconstruct from. | | `PATTER_DASHBOARD_NOTIFY` | enabled | Set to `0`, `false`, `no`, or `off` (case-insensitive) to skip the fire-and-forget dashboard ingest POST. Use this when you embed Patter alongside your own FastAPI server on port 8000 to avoid 404 spam in your access log. | | `PATTER_BIND_HOST` | `127.0.0.1` | Host the embedded server binds to. Set to `0.0.0.0` when running inside a container whose port must be reachable from the host (e.g. `docker run -p 8000:8000` — Docker's port-mapping cannot forward to a 127.0.0.1 listener inside the container). | @@ -129,8 +129,8 @@ By default the dashboard is an in-memory ring buffer — restart the process and | `persist` value | Behaviour | |-----------------|-----------| -| omitted / `None` (default) | Falls back to the `PATTER_LOG_DIR` env var. If the env var is also unset, persistence is **off** — backward-compatible with prior releases. | -| `False` | Force-off. Disk writes are skipped even when `PATTER_LOG_DIR` is set. | +| omitted / `None` (default) | Reads `PATTER_LOG_DIR` if set; **otherwise falls back to the platform default location**. Persistence is **on by default** since 0.6.2 so the dashboard's hydrate path recovers history across process restarts. | +| `False` | Force-off. Disk writes are skipped even when `PATTER_LOG_DIR` is set. Use this for ephemeral RAM-only behaviour. | | `True` | Write under the platform default location (see below). Equivalent to `PATTER_LOG_DIR=auto`. | | `""` (string) | Write under the supplied path (`~` is expanded). Equivalent to `PATTER_LOG_DIR=`. | @@ -215,7 +215,7 @@ export PATTER_LOG_RETENTION_DAYS=0 ``` -Retention defaults to **30 days** and phone numbers in `metadata.json` are **masked by default** (last 4 digits) via `PATTER_LOG_REDACT_PHONE`. If you need to keep call history indefinitely or store full E.164 numbers, set those env vars explicitly — and gate access to the log root, since `transcript.jsonl` is never redacted and may contain customer PII spoken during the call. +Retention defaults to **30 days**. Phone numbers in `metadata.json` default to **full** (raw E.164) since 0.6.2 so the dashboard's reveal toggle has something to reconstruct from — set `PATTER_LOG_REDACT_PHONE=mask` for setups that ship logs off-host. Gate access to the log root regardless, since `transcript.jsonl` is never redacted and may contain customer PII spoken during the call. See [Call logging](/python-sdk/call-logging) for the full layout, schema, and reading patterns. diff --git a/docs/python-sdk/engines.mdx b/docs/python-sdk/engines.mdx index da27894b..0e64b2f3 100644 --- a/docs/python-sdk/engines.mdx +++ b/docs/python-sdk/engines.mdx @@ -1,6 +1,6 @@ --- title: "Engines" -description: "End-to-end speech-to-speech runtimes (OpenAI Realtime, ElevenLabs ConvAI)." +description: "End-to-end speech-to-speech runtimes (OpenAI Realtime, OpenAI Realtime 2, ElevenLabs ConvAI)." icon: "bolt" --- @@ -8,9 +8,10 @@ icon: "bolt" An **engine** is an end-to-end speech-to-speech runtime. Pass an engine instance to `phone.agent(engine=...)` and Patter wires the audio stream straight through to the provider — no separate STT or TTS is needed. -Patter ships with two engine classes today: +Patter ships with three engine classes today: -- [`OpenAIRealtime`](#openairealtime) — OpenAI's Realtime API +- [`OpenAIRealtime`](#openairealtime) — OpenAI's Realtime API (v1-beta family, `gpt-realtime-mini` / `gpt-realtime` / `gpt-4o-*-realtime-preview`) +- [`OpenAIRealtime2`](#openairealtime2) — OpenAI's GA Realtime API (`gpt-realtime-2`), separate marker because the GA endpoint speaks a different `session.update` wire shape - [`ElevenLabsConvAI`](#elevenlabsconvai) — ElevenLabs Conversational AI Each class ships as both a **flat alias** (`from getpatter import OpenAIRealtime`) and a **namespaced** class (`from getpatter.engines import openai` → `openai.Realtime()`). They are equivalent. @@ -42,8 +43,10 @@ asyncio.run(main()) | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `api_key` | `str` | `""` | OpenAI API key. Reads from `OPENAI_API_KEY` when empty. | -| `voice` | `str` | `"alloy"` | One of `"alloy"`, `"echo"`, `"fable"`, `"onyx"`, `"nova"`, `"shimmer"`. | -| `model` | `str` | `"gpt-4o-mini-realtime-preview"` | OpenAI Realtime model ID. See [supported models](/python-sdk/providers/openai-realtime#models). | +| `voice` | `str` | `"alloy"` | One of `"alloy"`, `"ash"`, `"ballad"`, `"coral"`, `"echo"`, `"fable"`, `"nova"`, `"onyx"`, `"sage"`, `"shimmer"`, `"verse"`. | +| `model` | `str` | `"gpt-realtime-mini"` | OpenAI Realtime model ID. See [supported models](/python-sdk/providers/openai-realtime#models). | +| `reasoning_effort` | `"minimal" \| "low" \| "medium" \| "high" \| None` | `None` | Reasoning tier for `gpt-realtime-2`. `None` leaves the field unset (server default). OpenAI recommends `"low"` for production voice flows; higher tiers add measurable per-turn latency. No-op on models that ignore it. | +| `input_audio_transcription_model` | `str \| None` | `None` | Override the Realtime session's `input_audio_transcription.model`. `None` keeps the adapter default (`"whisper-1"`). Use `"gpt-realtime-whisper"` for low-latency partials, `"gpt-4o-transcribe"` for higher accuracy. | ### Supported model identifiers @@ -68,6 +71,49 @@ engine = openai_engine.Realtime() # reads OPENAI_API_KEY engine = openai_engine.Realtime(voice="nova", model="gpt-realtime-2") ``` +## OpenAIRealtime2 + +Marker class that selects the **GA Realtime API** (`gpt-realtime-2`). The GA endpoint speaks a different `session.update` wire shape than the v1-beta family (no `OpenAI-Beta: realtime=v1` header, `session.type: "realtime"`, nested `audio.{input,output}` with MIME types, `output_modalities` instead of `modalities`), so `OpenAIRealtime2` dispatches to a separate adapter (`OpenAIRealtime2Adapter`). + +```python +import asyncio +from getpatter import Patter, Twilio, OpenAIRealtime2 + +phone = Patter(carrier=Twilio(), phone_number="+15550001234") # TWILIO_* from env + +agent = phone.agent( + engine=OpenAIRealtime2(reasoning_effort="low"), # OPENAI_API_KEY from env + system_prompt="You are a friendly receptionist.", + first_message="Hello! How can I help?", +) + +async def main(): + await phone.serve(agent) + +asyncio.run(main()) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `api_key` | `str` | `""` | OpenAI API key. Reads from `OPENAI_API_KEY` when empty. | +| `voice` | `str` | `"alloy"` | Same voice set as `OpenAIRealtime`. | +| `model` | `str` | `"gpt-realtime-2"` | Pinned to the GA model. Override only if OpenAI ships future GA-shaped models. | +| `reasoning_effort` | `"minimal" \| "low" \| "medium" \| "high" \| None` | `None` | `gpt-realtime-2` reasoning tier. `"low"` is OpenAI's recommendation for production voice flows. | +| `input_audio_transcription_model` | `str \| None` | `None` | Override for `audio.input.transcription.model`. `None` keeps the adapter default (`"whisper-1"`). | + +Namespaced form: + +```python +from getpatter.engines import openai_realtime_2 + +engine = openai_realtime_2.Realtime2() +engine = openai_realtime_2.Realtime2(reasoning_effort="low") +``` + + +PCM transport: the GA endpoint accepts only PCM-16-LE at >=24 kHz. Patter transcodes inbound mulaw 8 kHz → PCM 24 kHz and outbound PCM 24 kHz → mulaw 8 kHz transparently on the carrier side; you don't need to configure anything. + + ## ElevenLabsConvAI ElevenLabs Conversational AI — premium voice quality using a managed agent configured in the ElevenLabs dashboard. diff --git a/docs/python-sdk/features.mdx b/docs/python-sdk/features.mdx index 1e1acb8a..6a10d2f0 100644 --- a/docs/python-sdk/features.mdx +++ b/docs/python-sdk/features.mdx @@ -39,20 +39,30 @@ for record in recordings: Detect whether a human or machine answered an outbound call. When a machine is detected, optionally leave a voicemail message and hang up. +AMD is **on by default** since 0.6.2. On Twilio, Patter uses `MachineDetection=DetectMessageEnd` + Async AMD so there is no answer-latency penalty on human pickups — the call connects immediately and the classification arrives via the `/webhooks/twilio/amd` callback. Pass `machine_detection=False` to skip per-call AMD billing when the destination is known to be a human. + ```python -# Enable AMD on outbound calls +# AMD is on by default; just pass a voicemail_message to leave a message +# when a machine answers. await phone.call( to="+15550009876", agent=agent, - machine_detection=True, voicemail_message="Hi, this is Acme Corp calling about your appointment. Please call us back at 555-000-1234.", ) + +# Or opt out explicitly for a known-human destination +await phone.call( + to="+15550009876", + agent=agent, + machine_detection=False, +) ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `machine_detection` | `bool` | `False` | Enable answering machine detection. | -| `voicemail_message` | `str` | `""` | Message to speak when a machine is detected. If empty, the call hangs up silently. | +| `machine_detection` | `bool` | `True` | Enable answering machine detection. Defaults on since 0.6.2. Pass `False` to skip AMD billing. | +| `voicemail_message` | `str` | `""` | Message to speak when a machine is detected. If empty, the call hangs up silently. A non-empty value implicitly enables AMD even if `machine_detection=False`. | +| `on_machine_detection` | `Callable[[MachineDetectionResult], Awaitable[None] \| None] \| None` | `None` | Fires once when the carrier reports the AMD outcome (`human` or `machine`). Useful for acceptance tests that need to mark a run INVALID when classification is not `human`. | ### How It Works diff --git a/docs/python-sdk/local-mode.mdx b/docs/python-sdk/local-mode.mdx index ad1ebb85..d163d0e1 100644 --- a/docs/python-sdk/local-mode.mdx +++ b/docs/python-sdk/local-mode.mdx @@ -80,41 +80,55 @@ await phone.serve(agent, port=8000) ## Making Outbound Calls -In local mode, use `call()` to make outbound calls while the server is running: +In local mode, use `call()` to make outbound calls while the server is running. + +**Important:** the server must be fully initialized before you call `phone.call()`. Use `await phone.ready` — it resolves once the tunnel is up, the embedded server is in `listen` state, and the carrier webhook is configured. This is the reliable replacement for `asyncio.sleep()` guesswork: ```python import asyncio async def main(): - # Start the server in background + # Start the server in the background — don't await it yet. server_task = asyncio.create_task(phone.serve(agent, port=8000)) - # Wait a moment for the server to be ready - await asyncio.sleep(1) - - # Make an outbound call - await phone.call( - to="+15550009876", - agent=agent, - machine_detection=True, - voicemail_message="Please call us back at 555-000-1234.", - ) - - # Keep the server running - await server_task + # Block until tunnel + HTTP server + carrier webhook are all wired up. + # Resolves to the public webhook hostname as a string. + await phone.ready + + try: + # Now safe to make outbound calls + await phone.call( + to="+15550009876", + agent=agent, + machine_detection=True, + voicemail_message="Please call us back at 555-000-1234.", + ) + finally: + # Clean shutdown + await phone.disconnect() + server_task.cancel() asyncio.run(main()) ``` +`phone.ready` rejects with the underlying exception if `serve()` fails before the server reaches `listen` state, so you get an immediate error instead of a hanging `await`. + +**Advanced — tunnel hostname only:** if you only need the public URL (for example, to register a webhook manually) without waiting for the HTTP server to be in `listen` state, use `await phone.tunnel_ready` instead. It resolves earlier but a `phone.call()` placed immediately afterwards can race the WebSocket upgrade path and produce a dropped call on answer. + ### call() Parameters (Local Mode) +All keyword arguments are **snake_case** (e.g. `machine_detection=`, `ring_timeout=`, `on_machine_detection=`). + | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `to` | `str` | *required* | Phone number to call (E.164 format). | | `agent` | `Agent` | *required* | Agent instance to use for this call. | +| `first_message` | `str` | `""` | What the AI says when the callee answers. | | `from_number` | `str` | `""` | Override the configured phone number. | -| `machine_detection` | `bool` | `False` | Enable answering machine detection. | -| `voicemail_message` | `str` | `""` | Message to leave on voicemail (requires `machine_detection=True`). | +| `machine_detection` | `bool` | `True` | Enable answering machine detection. Defaults **on** since 0.6.2 — on Twilio Patter uses `MachineDetection=DetectMessageEnd` + Async AMD so there is no answer-latency penalty on human pickups. Pass `False` to skip per-call AMD billing for known destinations. | +| `on_machine_detection` | `Callable[[MachineDetectionResult], Awaitable[None] \| None] \| None` | `None` | Fires once when the carrier reports the AMD outcome (`human` or `machine`). | +| `voicemail_message` | `str` | `""` | Message to leave on voicemail. A non-empty value also implicitly enables `machine_detection`. | +| `ring_timeout` | `int \| None` | `25` | Ring timeout in seconds before treating the call as no-answer. Defaults to 25 s — production-recommended. Pass `60` for legacy carrier-default parity, or `None` to omit the parameter entirely (carrier picks its own default). | --- @@ -149,11 +163,15 @@ The `LocalConfig` dataclass holds all provider credentials for local mode. It is | `twilio_token` | `str` | Twilio Auth Token (unpacked from `Twilio(...)`). | | `telnyx_key` | `str` | Telnyx API key (unpacked from `Telnyx(...)`). | | `telnyx_connection_id` | `str` | Telnyx Call Control Application ID (unpacked from `Telnyx(...)`). | +| `telnyx_public_key` | `str` | Telnyx Ed25519 public key for webhook signature verification (optional). | | `openai_key` | `str` | OpenAI API key (resolved from `OpenAIRealtime(...)` or `OPENAI_API_KEY`). | | `elevenlabs_key` | `str` | ElevenLabs API key. | | `deepgram_key` | `str` | Deepgram API key. | +| `cartesia_key`, `rime_key`, `lmnt_key`, `soniox_key`, `speechmatics_key`, `assemblyai_key` | `str` | Provider-specific keys backfilled from the matching constructor or env var. | | `phone_number` | `str` | Phone number in E.164 format. | | `webhook_url` | `str` | Public hostname (no scheme). | +| `require_signature` | `bool` | When `True` (default), inbound webhooks with missing credentials return HTTP 503 instead of silently accepting. Disable only for local mock-provider testing. | +| `persist_root` | `str \| None` | Resolved persistence path for the dashboard's on-disk call history, or `None` to disable. Set by the `persist=` argument on `Patter(...)` (with `PATTER_LOG_DIR` env fallback). | --- diff --git a/docs/python-sdk/providers/elevenlabs-tts.mdx b/docs/python-sdk/providers/elevenlabs-tts.mdx index 514682dc..8f79c9b9 100644 --- a/docs/python-sdk/providers/elevenlabs-tts.mdx +++ b/docs/python-sdk/providers/elevenlabs-tts.mdx @@ -86,6 +86,18 @@ const ttsTelnyx = ElevenLabsTTS.forTelnyx({ voiceId: "rachel" }); `for_twilio` / `forTwilio` saves ~30–80 ms first-byte plus per-frame CPU and removes a potential aliasing source. If your Telnyx profile is pinned to PCMU/8000 instead, construct directly with `output_format='ulaw_8000'`. +### Carrier auto-detect — `set_telephony_carrier` + +When you don't know the carrier at construction time, `StreamHandler` calls `set_telephony_carrier(carrier)` at call start to advise the provider of the wire format: + +```python +tts = ElevenLabsTTS() # output_format defaults to pcm_16000 +tts.set_telephony_carrier("twilio") # auto-flips to ulaw_8000 +tts.set_telephony_carrier("telnyx") # keeps pcm_16000 (Telnyx default) +``` + +When `output_format` was passed explicitly to the constructor (or via `for_twilio` / `for_telnyx`), `set_telephony_carrier` is a no-op — the user's choice always wins. Calling with an unknown carrier (`""` / `"custom"`) is also a no-op. + ## Constructor diff --git a/docs/python-sdk/providers/openai-realtime-2.mdx b/docs/python-sdk/providers/openai-realtime-2.mdx new file mode 100644 index 00000000..4e2dee45 --- /dev/null +++ b/docs/python-sdk/providers/openai-realtime-2.mdx @@ -0,0 +1,122 @@ +--- +title: "OpenAI Realtime 2 (GA)" +description: "GA Realtime API engine — separate adapter that speaks the new session.update wire shape required by gpt-realtime-2." +icon: "bolt" +--- + +# OpenAI Realtime 2 + +`OpenAIRealtime2` is the engine marker for OpenAI's **GA Realtime API** (the production endpoint that replaces the beta `OpenAI-Beta: realtime=v1` channel). It targets `gpt-realtime-2` by default and routes through `OpenAIRealtime2Adapter` — a dedicated adapter that speaks the GA `session.update` wire shape and performs bidirectional audio transcoding (mulaw 8 kHz ↔ PCM 24 kHz) required by the GA audio engine. + +For the legacy beta endpoint and the lower-cost `gpt-realtime-mini` model, keep using [`OpenAIRealtime`](/python-sdk/providers/openai-realtime). The two engines coexist — pick `OpenAIRealtime2` only when you specifically want the GA endpoint or the `gpt-realtime-2` model. + + +The GA endpoint rejects the legacy `OpenAI-Beta: realtime=v1` header and expects `output_modalities`, nested `audio.{input,output}` blocks with MIME-type strings, and `session.type = "realtime"`. These wire-shape differences are why GA needs its own adapter — the beta `OpenAIRealtimeAdapter` cannot reach `gpt-realtime-2` reliably. + + +## When to use + +| Use `OpenAIRealtime2` when… | Stick with `OpenAIRealtime` when… | +|----------------------------|-----------------------------------| +| You want `gpt-realtime-2` — strongest instruction following + 128K context + configurable `reasoning_effort`. | You're on `gpt-realtime-mini` for cost / latency reasons. | +| You're hitting the GA endpoint and the beta channel is being deprecated for your account. | You don't need the GA wire shape and want to keep the existing adapter path. | +| You want the bidirectional PCM 24 kHz transcoding handled by the SDK rather than the model silently dropping mulaw frames. | Your audio is already PCM 24 kHz end-to-end and beta works for you. | + +## Quickstart + +```python +import asyncio + +from getpatter import Patter, Twilio, OpenAIRealtime2 + +phone = Patter(carrier=Twilio(), phone_number="+15555550100") # TWILIO_* from env + +agent = phone.agent( + engine=OpenAIRealtime2(reasoning_effort="low"), + system_prompt="You are a friendly receptionist.", + first_message="Hello! How can I help today?", +) + +async def main() -> None: + await phone.serve(agent) + +asyncio.run(main()) +``` + +`reasoning_effort="low"` is OpenAI's recommended production tier for live voice — it gives the best instruction following without measurable per-turn latency. + +## Constructor + +```python +from getpatter import OpenAIRealtime2 + +OpenAIRealtime2( + api_key: str = "", # reads OPENAI_API_KEY + voice: str = "alloy", + model: str = "gpt-realtime-2", + reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None, + input_audio_transcription_model: str | None = None, # default: whisper-1 +) +``` + +All fields are optional with safe defaults. `api_key` falls back to the `OPENAI_API_KEY` environment variable. + +### Reasoning effort + +| Value | When to use | +|-------|-------------| +| `"minimal"` | Snappy turn-taking. Skips most reasoning. | +| `"low"` | **Recommended for production voice.** Good instruction following without measurable per-turn latency. | +| `"medium"` | Multi-step tool flows where the model should plan. Adds latency. | +| `"high"` | Complex reasoning. Not recommended for live phone calls. | + +When set, Patter injects `session.reasoning = { effort: ... }` into the GA `session.update` payload. When omitted, the field is not sent and OpenAI's server default applies. + +### Streaming transcription + +Set `input_audio_transcription_model` to override `audio.input.transcription.model`. The same identifiers as the beta endpoint apply — see the [streaming-transcription table on the OpenAI Realtime page](/python-sdk/providers/openai-realtime#streaming-transcription) for the full list (`whisper-1`, `gpt-4o-mini-transcribe`, `gpt-4o-transcribe`, `gpt-realtime-whisper`). + +## Audio path + +The GA audio engine speaks PCM 24 kHz and silently drops mulaw frames. Patter handles the conversion transparently inside `OpenAIRealtime2Adapter`: + +- **Inbound** (Twilio/Telnyx → model): mulaw 8 kHz → PCM 24 kHz +- **Outbound** (model → Twilio/Telnyx): PCM 24 kHz → mulaw 8 kHz + +No caller-side change is required — both Twilio Media Streams (mulaw 8 kHz) and Telnyx Call Control (PCM 16 kHz / mulaw 8 kHz) work out of the box. + +## Direct adapter use + +`OpenAIRealtime2Adapter` is exported and may be constructed directly when you need to share connection state across calls or override low-level fields: + +```python +from getpatter import OpenAIRealtime2Adapter + +adapter = OpenAIRealtime2Adapter( + api_key="", # reads OPENAI_API_KEY + model="gpt-realtime-2", + voice="nova", + instructions="You are a helpful assistant.", + reasoning_effort="low", + input_audio_transcription_model="gpt-realtime-whisper", +) + +agent = phone.agent(engine=adapter, system_prompt="...", first_message="...") +``` + +The adapter subclasses `OpenAIRealtimeAdapter` and overrides `connect()`, `send_audio()`, `receive_events()`, and `send_first_message()` for the GA wire shape. + +## Backward compatibility + +- Existing `OpenAIRealtime(...)` callers are **unaffected**. The legacy engine continues to target the beta endpoint with `gpt-realtime-mini` as the default. +- `OpenAIRealtime2` ships as an additive engine — no migration required. Pick it when you want the GA endpoint; otherwise stay where you are. +- Pricing for `gpt-realtime-2` is auto-resolved per model from `DEFAULT_PRICING["openai_realtime"].models["gpt-realtime-2"]` — see [Metrics](/python-sdk/metrics). + +## What's Next + + + The legacy engine for `gpt-realtime-mini` and earlier preview models. + All engine classes side by side. + Configure system prompts, tools, and first messages. + Function calling inside a Realtime session. + diff --git a/docs/python-sdk/providers/openai-realtime.mdx b/docs/python-sdk/providers/openai-realtime.mdx index 9f3d33cf..efdf3faf 100644 --- a/docs/python-sdk/providers/openai-realtime.mdx +++ b/docs/python-sdk/providers/openai-realtime.mdx @@ -113,9 +113,9 @@ asyncio.run(main()) ``` - -The `reasoning_effort` and `input_audio_transcription_model` arguments live on `OpenAIRealtimeAdapter`. The shorthand `OpenAIRealtime(model=...)` engine wrapper currently exposes only `api_key`, `voice`, and `model` — use the adapter directly when you need the new fields. - + +Since 0.6.2 you can pass `reasoning_effort` and `input_audio_transcription_model` directly to the engine wrapper — `OpenAIRealtime(model="gpt-realtime-2", reasoning_effort="low", input_audio_transcription_model="gpt-realtime-whisper")`. Reach for the lower-level `OpenAIRealtimeAdapter` only when you need every field (custom VAD type, modalities, `silence_duration_ms`, etc.). For the GA `gpt-realtime-2` endpoint, prefer the dedicated [`OpenAIRealtime2`](/python-sdk/engines#openairealtime2) marker — it dispatches to a separate adapter that handles the GA-shape `session.update` wire format automatically. + ## Backward compatibility diff --git a/docs/python-sdk/providers/silero-vad.mdx b/docs/python-sdk/providers/silero-vad.mdx index 1c8fcfff..321632b7 100644 --- a/docs/python-sdk/providers/silero-vad.mdx +++ b/docs/python-sdk/providers/silero-vad.mdx @@ -48,8 +48,8 @@ vad = await asyncio.to_thread(SileroVAD.for_phone_call) # Or, full control: vad = SileroVAD.load( - activation_threshold=0.5, # Silero `threshold` - deactivation_threshold=0.35, # `neg_threshold = threshold - 0.15` + activation_threshold=0.8, # Silero `threshold`; 0.8 tuned for telephony + deactivation_threshold=0.65, # `neg_threshold = threshold - 0.15` min_speech_duration=0.25, # seconds, `min_speech_duration_ms = 250` min_silence_duration=0.1, # seconds, `min_silence_duration_ms = 100` prefix_padding_duration=0.03, # seconds, `speech_pad_ms = 30` @@ -66,8 +66,8 @@ const vad = await SileroVAD.forPhoneCall(); // Or, full control: const vad2 = await SileroVAD.load({ - activationThreshold: 0.5, - deactivationThreshold: 0.35, + activationThreshold: 0.8, + deactivationThreshold: 0.65, minSpeechDuration: 0.25, minSilenceDuration: 0.1, prefixPaddingDuration: 0.03, @@ -79,15 +79,15 @@ const vad2 = await SileroVAD.load({ ### Phone-call preset (`for_phone_call` / `forPhoneCall`) -Identical to `load()` but pins `sample_rate` to 16000 Hz — the only sample rate Patter's pipeline-mode audio bus uses (8 kHz mulaw from Twilio is upsampled to 16 kHz PCM before reaching the VAD). All other parameters mirror the upstream Silero defaults from `snakers4/silero-vad`: +Identical to `load()` but pins `sample_rate` to 16000 Hz — the only sample rate Patter's pipeline-mode audio bus uses (8 kHz mulaw from Twilio is upsampled to 16 kHz PCM before reaching the VAD). Parameters are tuned for telephony-band audio (not the upstream Silero studio defaults): -- `activation_threshold = 0.5` — upstream `threshold` -- `deactivation_threshold = 0.35` — upstream `neg_threshold = threshold - 0.15` +- `activation_threshold = 0.8` — raised from upstream default 0.5 to filter background noise on telephony +- `deactivation_threshold = 0.65` — raised from upstream default 0.35 to match activation with no hysteresis gap - `min_speech_duration = 0.25` — upstream `min_speech_duration_ms = 250` - `min_silence_duration = 0.1` — upstream `min_silence_duration_ms = 100` - `prefix_padding_duration = 0.03` — upstream `speech_pad_ms = 30` -Override any field via keyword arguments. Deployments that experience truncation on natural pauses can raise `min_silence_duration` (e.g. 0.5–1.0 s): +Override any field via keyword arguments. Deployments that experience truncation on natural pauses can raise `min_silence_duration` (e.g. 0.5–1.0 s). To restore the upstream Silero defaults for studio audio, pass `activation_threshold=0.5, deactivation_threshold=0.35`: ```python Python diff --git a/docs/python-sdk/quickstart.mdx b/docs/python-sdk/quickstart.mdx index db97c7a6..487be9d7 100644 --- a/docs/python-sdk/quickstart.mdx +++ b/docs/python-sdk/quickstart.mdx @@ -110,6 +110,10 @@ You'll see the Patter banner, a Cloudflare tunnel URL, and a log line confirming Pick up your phone, dial your Twilio number, and the agent will answer with `"Hello! How can I help?"`. Start talking. + +Since 0.6.2, `Patter(...)` persists per-call records (`metadata.json`, `transcript.jsonl`, `events.jsonl`) to the platform default data directory by default so the local dashboard's call history survives process restarts. Pass `persist=False` to keep the old ephemeral-RAM-only behaviour, or `persist="/custom/path"` to choose a different location. See [Call logging](/python-sdk/call-logging) for the full layout. + + --- ## Using a different engine @@ -146,6 +150,36 @@ Swap `AnthropicLLM()` for `OpenAILLM()`, `GroqLLM()`, `CerebrasLLM()`, or `Googl --- +## Non-English agents + +OpenAI Realtime auto-detects the spoken language from the inbound audio and matches the language of your `system_prompt`. Writing the prompt in the target language is the primary control: + +```python +# Japanese — write the prompt in Japanese. +agent = phone.agent( + engine=OpenAIRealtime(), + system_prompt="あなたは丁寧な日本語のアシスタントです。", + first_message="お電話ありがとうございます。ご用件をお伺いします。", +) +``` + +The `language="ja"` parameter on `OpenAIRealtime` only seeds the auto-generated fallback prompt (`"Respond in {language}"`) when you don't supply your own `system_prompt`. It does not configure the Realtime API session itself. + +For pipeline mode the STT needs the language code so it can pick the right acoustic model: + +```python +agent = phone.agent( + stt=DeepgramSTT(language="ja"), # required: Deepgram needs the JP model + llm=AnthropicLLM(), # LLM responds in whatever language you prompt it in + tts=ElevenLabsTTS(voice_id="..."), # ElevenLabs auto-detects from text; pick a voice that supports JP + system_prompt="あなたは丁寧な日本語のアシスタントです。", +) +``` + +**E.164 formatting for international calls:** outbound `to=` values must be E.164. Many regions drop the trunk-zero — Japanese numbers like `090-xxxx-xxxx` are dialled as `+81XXXXXXXXX` (drop the leading `0`). + +--- + ## What's next diff --git a/docs/python-sdk/reference.mdx b/docs/python-sdk/reference.mdx index ff01bfdb..3b7e1a04 100644 --- a/docs/python-sdk/reference.mdx +++ b/docs/python-sdk/reference.mdx @@ -19,8 +19,9 @@ Patter( carrier: Twilio | Telnyx | None = None, phone_number: str = "", webhook_url: str = "", - tunnel: CloudflareTunnel | Static | bool | None = None, + tunnel: CloudflareTunnel | Static | Ngrok | bool | None = None, pricing: dict | None = None, + persist: bool | str | None = None, ) ``` @@ -29,8 +30,9 @@ Patter( | `carrier` | `Twilio \| Telnyx \| None` | `None` | Telephony carrier instance. Reads credentials from env vars when arguments are omitted. | | `phone_number` | `str` | `""` | Phone number in E.164 format. | | `webhook_url` | `str` | `""` | Public hostname, no scheme. | -| `tunnel` | `CloudflareTunnel \| Static \| bool \| None` | `None` | Tunnel directive. `True` is shorthand for `CloudflareTunnel()`. | +| `tunnel` | `CloudflareTunnel \| Static \| Ngrok \| bool \| None` | `None` | Tunnel directive. `True` is shorthand for `CloudflareTunnel()`. | | `pricing` | `dict \| None` | `None` | Override default provider pricing. See [Metrics & Cost Tracking](/python-sdk/metrics). | +| `persist` | `bool \| str \| None` | `None` | Persistent dashboard history. `None` (default) → falls back to `PATTER_LOG_DIR`, then platform default — i.e. persistence is ON by default since 0.6.2. `False` force-off. `True` → platform default path. String → explicit path (`~` expanded). See [Configuration](/python-sdk/configuration#persistent-dashboard-history). | --- @@ -41,12 +43,12 @@ Patter( ```python def agent( system_prompt: str, - engine: OpenAIRealtime | ElevenLabsConvAI | None = None, + engine: OpenAIRealtime | OpenAIRealtime2 | ElevenLabsConvAI | None = None, stt: STTProvider | None = None, llm: LLMProvider | None = None, tts: TTSProvider | None = None, voice: str = "alloy", - model: str = "gpt-4o-mini-realtime-preview", + model: str = "gpt-realtime-mini", language: str = "en", first_message: str = "", tools: list[Tool] | None = None, @@ -60,20 +62,23 @@ def agent( barge_in_threshold_ms: int = 300, aggressive_first_flush: bool = False, disable_phone_preamble: bool = False, + echo_cancellation: bool = False, + mcp_servers: list | None = None, + prewarm_first_message: bool | None = None, ) -> Agent ``` -Pass `engine=OpenAIRealtime(...)` or `engine=ElevenLabsConvAI(...)` for end-to-end engines; omit `engine=` and pass `stt=`/`tts=` for pipeline mode. +Pass `engine=OpenAIRealtime(...)`, `engine=OpenAIRealtime2(...)`, or `engine=ElevenLabsConvAI(...)` for end-to-end engines; omit `engine=` and pass `stt=`/`tts=` for pipeline mode. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `system_prompt` | `str` | *required* | Agent instructions. | -| `engine` | `OpenAIRealtime \| ElevenLabsConvAI \| None` | `None` | End-to-end voice runtime. Omit for pipeline mode. | +| `engine` | `OpenAIRealtime \| OpenAIRealtime2 \| ElevenLabsConvAI \| None` | `None` | End-to-end voice runtime. Omit for pipeline mode. | | `stt` | `STTProvider \| None` | `None` | STT instance for pipeline mode (e.g. `DeepgramSTT()`). | | `llm` | `LLMProvider \| None` | `None` | LLM provider instance for pipeline mode (e.g. `AnthropicLLM()`). Mutually exclusive with `on_message` on `serve()`. Ignored when `engine` is set. | | `tts` | `TTSProvider \| None` | `None` | TTS instance for pipeline mode (e.g. `ElevenLabsTTS()`). | | `voice` | `str` | `"alloy"` | TTS voice name (when engine doesn't carry it). | -| `model` | `str` | `"gpt-4o-mini-realtime-preview"` | AI model ID (when engine doesn't carry it). | +| `model` | `str` | `"gpt-realtime-mini"` | AI model ID (when engine doesn't carry it). | | `language` | `str` | `"en"` | BCP-47 language code. | | `first_message` | `str` | `""` | Greeting spoken at call start. | | `tools` | `list[Tool] \| None` | `None` | `Tool(...)` instances for function calling. | @@ -87,6 +92,9 @@ Pass `engine=OpenAIRealtime(...)` or `engine=ElevenLabsConvAI(...)` for end-to-e | `barge_in_threshold_ms` | `int` | `300` | Barge-in hang-over window (ms). Set to `0` to disable. | | `aggressive_first_flush` | `bool` | `False` | Emit the first clause on a soft punctuation boundary (`,`, em/en-dash) once buffer ≥40 chars. Saves 200–500 ms TTFA. Hard-disabled when `language` starts with `"it"`. Pipeline mode only. | | `disable_phone_preamble` | `bool` | `False` | Disable the phone-friendly preamble Patter prepends to `system_prompt` (no markdown / emojis / lists, numbers spelled out, replies kept short). Default `False` keeps the preamble on. | +| `echo_cancellation` | `bool` | `False` | Pipeline mode only. Instantiates an `NlmsEchoCanceller` per call so the agent's own TTS bleed is removed from the inbound mic before VAD/STT. See [Echo Cancellation](/python-sdk/features#echo-cancellation-nlms-aec). | +| `mcp_servers` | `list \| None` | `None` | List of MCP server definitions exposed to the agent as tools. See [MCP](/python-sdk/mcp). | +| `prewarm_first_message` | `bool \| None` | `None` (=`False`) | Opt in to pre-rendering `first_message` TTS during the ringing window so playback starts instantly on pickup. Off by default in 0.6.2 — re-enable per agent when you've validated barge-in interaction. | **Raises:** `ValueError` if required credentials are missing or conflicting options are passed (e.g. both `engine` and `stt`/`tts`). @@ -120,17 +128,21 @@ Start the embedded server. Blocks until stopped. ```python async def call( to: str, + agent: Agent | None = None, first_message: str = "", from_number: str = "", - agent: Agent | None = None, - machine_detection: bool = False, - on_machine: Callable[[dict], Awaitable[None]] | None = None, + machine_detection: bool = True, + on_machine_detection: Callable[[MachineDetectionResult], Awaitable[None] | None] | None = None, voicemail_message: str = "", - ring_timeout: int | None = None, + ring_timeout: int | None = 25, ) -> None ``` -Make an outbound call. +Make an outbound call. Keyword arguments are **snake_case** — e.g. `machine_detection=`, `ring_timeout=`, `on_machine_detection=` (the latter was renamed from `on_machine` in 0.6.2; the callback receives a `MachineDetectionResult` not a raw dict). + +`machine_detection` defaults to **`True`** since 0.6.2 — on Twilio Patter uses `MachineDetection=DetectMessageEnd` + Async AMD so there is no answer-latency penalty on human pickups. Pass `machine_detection=False` to skip per-call AMD billing for known destinations. + +`ring_timeout` defaults to **25 seconds**, the production-recommended value. Pass `60` for legacy carrier-default parity, or `None` to omit the parameter entirely (carrier picks its own default). --- @@ -190,8 +202,8 @@ class Telnyx: ## Engines ```python -from getpatter import OpenAIRealtime, ElevenLabsConvAI # flat aliases -from getpatter.engines import openai, elevenlabs # namespaced +from getpatter import OpenAIRealtime, OpenAIRealtime2, ElevenLabsConvAI # flat aliases +from getpatter.engines import openai, openai_realtime_2, elevenlabs # namespaced ``` ### OpenAIRealtime @@ -201,9 +213,25 @@ from getpatter.engines import openai, elevenlabs # namespace class OpenAIRealtime: api_key: str = "" # reads OPENAI_API_KEY when empty voice: str = "alloy" - model: str = "gpt-4o-mini-realtime-preview" + model: str = "gpt-realtime-mini" + reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None + input_audio_transcription_model: str | None = None +``` + +### OpenAIRealtime2 + +```python +@dataclass(frozen=True) +class OpenAIRealtime2: + api_key: str = "" # reads OPENAI_API_KEY when empty + voice: str = "alloy" + model: str = "gpt-realtime-2" + reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None + input_audio_transcription_model: str | None = None ``` +Selects the GA Realtime API. Separate marker from `OpenAIRealtime` because the GA endpoint speaks a different `session.update` wire shape. See [Engines › OpenAIRealtime2](/python-sdk/engines#openairealtime2). + ### ElevenLabsConvAI ```python @@ -315,7 +343,7 @@ All data classes are frozen (immutable) dataclasses. class Agent: system_prompt: str voice: str = "alloy" - model: str = "gpt-4o-mini-realtime-preview" + model: str = "gpt-realtime-mini" language: str = "en" first_message: str = "" tools: list[dict] | None = None @@ -332,9 +360,11 @@ class Agent: barge_in_threshold_ms: int = 300 aggressive_first_flush: bool = False disable_phone_preamble: bool = False + echo_cancellation: bool = False + prewarm_first_message: bool = False ``` -`provider` is a closed string literal — only `"openai_realtime"`, `"elevenlabs_convai"`, or `"pipeline"` are valid. It is normally derived from `engine` / `stt`+`tts` and rarely set by hand. +`provider` is a closed string literal — `"openai_realtime"`, `"openai_realtime_2"`, `"elevenlabs_convai"`, or `"pipeline"`. It is normally derived from `engine` / `stt`+`tts` and rarely set by hand. ### CallEvent @@ -462,7 +492,7 @@ from getpatter import ( TwilioAdapter, TelnyxAdapter, # advanced: direct adapter access # Engines - OpenAIRealtime, ElevenLabsConvAI, + OpenAIRealtime, OpenAIRealtime2, ElevenLabsConvAI, # STT classes DeepgramSTT, WhisperSTT, OpenAITranscribeSTT, @@ -513,7 +543,7 @@ from getpatter import ( ) from getpatter.carriers import twilio, telnyx -from getpatter.engines import openai, elevenlabs +from getpatter.engines import openai, openai_realtime_2, elevenlabs from getpatter.stt import deepgram, whisper, openai_transcribe, cartesia, assemblyai, soniox, speechmatics from getpatter.tts import elevenlabs, openai, cartesia, rime, lmnt from getpatter.llm import openai, anthropic, groq, cerebras, google diff --git a/docs/python-sdk/test-mode.mdx b/docs/python-sdk/test-mode.mdx index f795a6c4..5e9ff2fb 100644 --- a/docs/python-sdk/test-mode.mdx +++ b/docs/python-sdk/test-mode.mdx @@ -31,7 +31,7 @@ This opens an interactive REPL: ============================================================ PATTER TEST MODE ============================================================ - Agent: gpt-4o-mini-realtime-preview / alloy + Agent: gpt-realtime-mini / alloy Provider: openai_realtime Call ID: test_a1b2c3d4e5f6 Caller: +15550000001 → Callee: +15550000002 diff --git a/docs/python-sdk/tts.mdx b/docs/python-sdk/tts.mdx index 987f7350..b9e28a02 100644 --- a/docs/python-sdk/tts.mdx +++ b/docs/python-sdk/tts.mdx @@ -83,7 +83,7 @@ tts = ElevenLabsTTS(api_key="...", voice_id="EXAVITQu4vr4xnSDxMaL", model_id="el | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `api_key` | `str \| None` | `None` | API key — reads from `ELEVENLABS_API_KEY` if omitted. | -| `voice_id` | `str` | `"EXAVITQu4vr4xnSDxMaL"` (Sarah) | ElevenLabs voice ID (or name). | +| `voice_id` | `str` | `"21m00Tcm4TlvDq8ikWAM"` (Rachel) | ElevenLabs voice ID (or name). | | `model_id` | `ElevenLabsModel \| str` | `"eleven_flash_v2_5"` | Typed literal: `eleven_flash_v2_5` / `eleven_turbo_v2_5` / `eleven_v3` / `eleven_multilingual_v2` / `eleven_monolingual_v1`. | | `output_format` | `str` | `"pcm_16000"` | ElevenLabs output format. | diff --git a/docs/typescript-sdk/agents.mdx b/docs/typescript-sdk/agents.mdx index bf13f68c..0e11e85a 100644 --- a/docs/typescript-sdk/agents.mdx +++ b/docs/typescript-sdk/agents.mdx @@ -59,7 +59,7 @@ Available LLM providers: `OpenAILLM`, `AnthropicLLM`, `GroqLLM`, `CerebrasLLM`, | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `systemPrompt` | `string` | Yes | — | Instructions that define the agent's persona and behavior. | -| `engine` | `OpenAIRealtime \| ElevenLabsConvAI` | No | defaults to `OpenAIRealtime` | End-to-end engine. See [Engines](/typescript-sdk/engines). Omit for pipeline mode. | +| `engine` | `OpenAIRealtime \| OpenAIRealtime2 \| ElevenLabsConvAI` | No | defaults to `OpenAIRealtime` when none provided and pipeline pieces are absent | End-to-end engine. See [Engines](/typescript-sdk/engines). Omit for pipeline mode. | | `stt` | `STTProvider` | No | — | STT instance for pipeline mode (`new DeepgramSTT()`, `new CartesiaSTT()`, ...). | | `llm` | `LLMProvider` | No | — | LLM instance for pipeline mode (`new AnthropicLLM()`, `new GroqLLM()`, ...). Mutually exclusive with `onMessage` on `serve()`. Ignored when `engine` is set. See [LLM](/typescript-sdk/llm). | | `tts` | `TTSProvider` | No | — | TTS instance for pipeline mode (`new ElevenLabsTTS()`, `new RimeTTS()`, ...). | @@ -78,6 +78,7 @@ Available LLM providers: `OpenAILLM`, `AnthropicLLM`, `GroqLLM`, `CerebrasLLM`, | `bargeInThresholdMs` | `number` | No | `300` | Barge-in hang-over window (ms). Set to `0` to disable. | | `aggressiveFirstFlush` | `boolean` | No | `false` | Opt-in low-latency mode: emits the first clause on soft punctuation (`,`, em-dash) once the buffer reaches ≥40 chars. Saves 200–500 ms TTFA. Hard-disabled when `language="it"` (Italian punctuation patterns are incompatible). | | `disablePhonePreamble` | `boolean` | No | `false` | When `false` (default), Patter prepends a phone-friendly preamble to `systemPrompt` that instructs the LLM to avoid markdown, emojis, bullet lists, and code blocks; spell out numbers and dates; and keep replies short. Set to `true` to ship `systemPrompt` verbatim. | +| `prewarmFirstMessage` | `boolean` | No | `false` (pipeline mode only) | Pre-render `firstMessage` to TTS audio bytes during the ringing window and stream the cached buffer the instant the call connects, eliminating the 200–700 ms TTS first-byte latency on the greeting. Realtime / ConvAI engines silently ignore the flag (with a `WARN` log) — only pipeline mode consumes the cache. Trade-off: pays for the greeting's TTS even when the call rings out unanswered (~$0.001–$0.005 per ring). Opt in explicitly for inbound calls and low-noise deployments: `prewarmFirstMessage: true`. | | `provider` | `'openai_realtime' \| 'elevenlabs_convai' \| 'pipeline'` | No | derived | Provider mode. Normally derived from `engine` / `stt` + `tts`. Pass `'pipeline'` explicitly when building a pipeline-mode agent without an engine instance. | ## Validation Rules @@ -136,8 +137,8 @@ const agent = phone.agent({ | Field | Default | Upstream equivalent | |-------|---------|---------------------| -| `activationThreshold` | `0.5` | `threshold` | -| `deactivationThreshold` | `0.35` | `neg_threshold = threshold − 0.15` | +| `activationThreshold` | `0.8` | `threshold` (tuned for telephony, not studio) | +| `deactivationThreshold` | `0.65` | `neg_threshold = threshold − 0.15` (tuned for telephony) | | `minSpeechDuration` | `0.25` s | `min_speech_duration_ms = 250` | | `minSilenceDuration` | `0.1` s | `min_silence_duration_ms = 100` | | `prefixPaddingDuration` | `0.03` s | `speech_pad_ms = 30` | diff --git a/docs/typescript-sdk/call-logging.mdx b/docs/typescript-sdk/call-logging.mdx index b73a6ac3..97473f5f 100644 --- a/docs/typescript-sdk/call-logging.mdx +++ b/docs/typescript-sdk/call-logging.mdx @@ -4,7 +4,7 @@ description: "Opt-in per-call filesystem logs: metadata, transcripts, and operat icon: "folder-open" --- -Patter can persist every call to a directory tree on disk so you can replay transcripts, audit tool calls, and track latency/cost trends without running a hosted dashboard. Logging is **opt-in and off by default** — nothing is written unless you ask for it. +Patter can persist every call to a directory tree on disk so you can replay transcripts, audit tool calls, and track latency/cost trends without running a hosted dashboard. Persistence is **on by default since 0.6.2** (writes land under the platform default location described below) so the dashboard rebuilds across process restarts without extra wiring. Pass `persist: false` to keep the prior ephemeral-RAM-only behaviour. The same on-disk layout also backs the local dashboard's call history: when persistence is enabled, `phone.serve()` rebuilds the in-memory dashboard from disk on startup so call history survives process restarts without an external database. @@ -30,7 +30,7 @@ const phone = new Patter({ | `persist` value | Behaviour | |-----------------|-----------| -| omitted / `undefined` (default) | Falls back to `PATTER_LOG_DIR`; off when env is also unset (backward-compatible). | +| omitted / `undefined` (default) | **On**. Falls back to `PATTER_LOG_DIR` when set; otherwise writes under the platform default location. (Changed from opt-in to default-on in 0.6.2 for dashboard hydrate.) | | `false` | Force-off, even if `PATTER_LOG_DIR` is set. | | `true` | Platform default location (see below). | | `""` | Use the supplied path (`~` expanded). | @@ -53,7 +53,7 @@ Platform defaults for `auto` (and for `persist: true`): - Linux: `$XDG_DATA_HOME/patter` (falls back to `~/.local/share/patter`) - Windows: `%LOCALAPPDATA%\patter` -When `persist` is unset and the env var is unset, the logger is a no-op — no directories are created, no files are written. When `persist` is set explicitly, the env var is ignored. +When `persist` is set explicitly the env var is ignored. When `persist` is left unset (the default) the logger uses `PATTER_LOG_DIR` if set, otherwise the platform default location. Pass `persist: false` to disable disk writes entirely. ## Layout @@ -159,7 +159,7 @@ for (const line of transcript.split('\n').filter(Boolean)) { ## Safety guarantees - File-write errors never raise into the call path — a full disk or a permissions hiccup logs a warning and the call continues uninterrupted. -- When persistence is disabled (`persist: false`, or `persist` unset and `PATTER_LOG_DIR` unset), `CallLogger.enabled` is `false` and every method returns immediately. +- When persistence is disabled (`persist: false`), `CallLogger.enabled` is `false` and every method returns immediately. ## Interop diff --git a/docs/typescript-sdk/carrier.mdx b/docs/typescript-sdk/carrier.mdx index 184cec4d..ee95553f 100644 --- a/docs/typescript-sdk/carrier.mdx +++ b/docs/typescript-sdk/carrier.mdx @@ -36,6 +36,10 @@ const phone = new Patter({ On `serve()`, Patter automatically sets the `voice_url` on the Twilio number to `https:///webhooks/twilio/voice` via the Twilio REST API — no manual Console configuration needed. +### How caller / callee reach the agent + +Inbound Twilio calls deliver the caller and callee numbers via TwiML `` / `` children of `` — Twilio surfaces these on the WS `start` frame as `start.customParameters`. Patter's `/webhooks/twilio/voice` route emits this TwiML automatically. If you construct the TwiML yourself, build it with `TwilioAdapter.generateStreamTwiml(streamUrl, { caller, callee })` so the values land on the WS `start` frame. Query-string parameters on the `` are stripped by Twilio before the WS handshake and will not work. + ### Signature verification The Auth Token is also used to verify every Twilio webhook with HMAC-SHA1 against the `X-Twilio-Signature` header. Requests with invalid signatures are rejected with HTTP 403. @@ -67,6 +71,10 @@ const phone = new Patter({ | `connectionId` | `string` | — | Call Control Application ID. Reads from `TELNYX_CONNECTION_ID` when omitted. | | `publicKey` | `string` | — | Optional. Ed25519 public key for webhook signature verification. Reads from `TELNYX_PUBLIC_KEY` when omitted. | +### How caller / callee reach the agent + +The Telnyx WS upgrade URL carries the metadata as query-string parameters: `wss:///ws/stream/?caller=&callee=`. Telnyx preserves the query string through the WebSocket handshake, so no equivalent of TwiML `` is needed. + ### Signature verification When `publicKey` is set (or `TELNYX_PUBLIC_KEY` is present), every Telnyx webhook is verified with Ed25519. Requests older than 5 minutes are rejected (replay protection). diff --git a/docs/typescript-sdk/configuration.mdx b/docs/typescript-sdk/configuration.mdx index a4ad78cd..39678081 100644 --- a/docs/typescript-sdk/configuration.mdx +++ b/docs/typescript-sdk/configuration.mdx @@ -27,7 +27,7 @@ The carrier instance reads credentials from environment variables when you don't | `webhookUrl` | `string` | Conditional | — | Public hostname for webhooks (no protocol prefix, no path). Required unless using `tunnel: true` in `serve()`. | | `tunnel` | `CloudflareTunnel \| StaticTunnel \| boolean` | No | — | Tunnel directive. `true` is shorthand for `new CloudflareTunnel()`. See [Tunneling](/dev-tools/tunneling). | | `pricing` | `Record>` | No | — | Override default provider pricing estimates. See [Metrics](/typescript-sdk/metrics). | -| `persist` | `boolean \| string` | No | — | Persist the dashboard's call history to disk so it survives process restarts. See [Persistent dashboard history](#persistent-dashboard-history) below. | +| `persist` | `boolean \| string` | No | `true` (on, platform default path) | Persist the dashboard's call history to disk so it survives process restarts. Defaults to ON since 0.6.2 — pass `persist: false` to keep the prior ephemeral-RAM-only behaviour. See [Persistent dashboard history](#persistent-dashboard-history) below. | ## Webhook URL Format @@ -134,7 +134,7 @@ By default the dashboard is an in-memory ring buffer — restart the process and | `persist` value | Behaviour | |-----------------|-----------| -| omitted / `undefined` (default) | Falls back to the `PATTER_LOG_DIR` env var. If the env var is also unset, persistence is **off** — backward-compatible with prior releases. | +| omitted / `undefined` (default) | Persistence is **on**. Falls back to `PATTER_LOG_DIR` when set; otherwise writes under the platform default location. Changed from opt-in to default-on in 0.6.2 so the dashboard's hydrate path can rebuild call history across process restarts without extra wiring. | | `false` | Force-off. Disk writes are skipped even when `PATTER_LOG_DIR` is set. | | `true` | Write under the platform default location (see below). Equivalent to `PATTER_LOG_DIR=auto`. | | `""` (string) | Write under the supplied path (`~` is expanded). Equivalent to `PATTER_LOG_DIR=`. | diff --git a/docs/typescript-sdk/dashboard.mdx b/docs/typescript-sdk/dashboard.mdx index a34fec56..93958402 100644 --- a/docs/typescript-sdk/dashboard.mdx +++ b/docs/typescript-sdk/dashboard.mdx @@ -17,7 +17,8 @@ Patter ships a built-in web dashboard for monitoring calls in real time. It runs The dashboard is enabled by default whenever you start a server in local mode: ```typescript -await phone.serve(agent, { +await phone.serve({ + agent, port: 8000, dashboard: true, // Enable dashboard (default: true) dashboardToken: "secret", // Optional: protect with a token @@ -35,7 +36,8 @@ Once running, open your browser at `http://127.0.0.1:8000/`. Protect the dashboard with a token: ```typescript -await phone.serve(agent, { +await phone.serve({ + agent, port: 8000, dashboardToken: "my-secret-token", }); diff --git a/docs/typescript-sdk/engines.mdx b/docs/typescript-sdk/engines.mdx index 6e8a1afc..21b1f7f8 100644 --- a/docs/typescript-sdk/engines.mdx +++ b/docs/typescript-sdk/engines.mdx @@ -8,12 +8,13 @@ icon: "bolt" An **engine** is an end-to-end speech-to-speech runtime. Pass an engine instance to `phone.agent({ engine })` and Patter wires the audio stream straight through to the provider — no separate STT or TTS is needed. -Patter ships with two engine classes today: +Patter ships with three engine classes today: -- [`OpenAIRealtime`](#openairealtime) — OpenAI's Realtime API +- [`OpenAIRealtime`](#openairealtime) — OpenAI's Realtime API (beta endpoint) +- [`OpenAIRealtime2`](#openairealtime2) — OpenAI's GA Realtime API (targets `gpt-realtime-2`) - [`ElevenLabsConvAI`](#elevenlabsconvai) — ElevenLabs Conversational AI -Both classes are imported by name from the package barrel: `import { OpenAIRealtime, ElevenLabsConvAI } from "getpatter"`. +All three classes are imported by name from the package barrel: `import { OpenAIRealtime, OpenAIRealtime2, ElevenLabsConvAI } from "getpatter"`. If you need full control over STT, LLM, and TTS independently, use [pipeline mode](/typescript-sdk/llm#pipeline-mode) instead and omit `engine`. @@ -41,6 +42,8 @@ await phone.serve({ agent }); | `apiKey` | `string` | — | OpenAI API key. Reads from `OPENAI_API_KEY` when omitted. | | `voice` | `string` | `"alloy"` | One of `"alloy"`, `"ash"`, `"ballad"`, `"coral"`, `"echo"`, `"sage"`, `"shimmer"`, `"verse"`. | | `model` | `string` | `"gpt-4o-mini-realtime-preview"` | OpenAI Realtime model ID. See [supported models](/typescript-sdk/providers/openai-realtime#models). | +| `reasoningEffort` | `"minimal" \| "low" \| "medium" \| "high"` | — | Reasoning-effort tier for `gpt-realtime-2`. When omitted, the field is not sent and the server default applies. OpenAI recommends `"low"` for production voice. | +| `inputAudioTranscriptionModel` | `string` | — | Override for the Realtime session's `input_audio_transcription.model`. Omit to keep `whisper-1`. | ### Supported model identifiers @@ -48,14 +51,45 @@ The `model` option accepts any OpenAI Realtime model ID. Common values: | Model | Notes | |-------|-------| -| `"gpt-realtime-mini"` | Default. Lowest latency / lowest cost. | +| `"gpt-4o-mini-realtime-preview"` | Engine marker default. Earlier preview line — cheap and low-latency. | +| `"gpt-realtime-mini"` | GA mini — recommended cheap default for new deployments. | | `"gpt-realtime"` | GA realtime model (Aug 2025). | -| `"gpt-realtime-2"` | Most-capable: stronger instruction following, configurable `reasoningEffort`, 128K context. | +| `"gpt-realtime-2"` | Most-capable: stronger instruction following, configurable `reasoningEffort`, 128K context. Use [`OpenAIRealtime2`](#openairealtime2) for the GA wire shape. | | `"gpt-4o-realtime-preview"` | Earlier preview line; ~10x the per-token cost of mini. | -| `"gpt-4o-mini-realtime-preview"` | Earlier preview line. | Pricing is auto-resolved per model — see [Metrics](/typescript-sdk/metrics). For `reasoningEffort`, transcription model, and the full configuration surface, see [OpenAI Realtime — full reference](/typescript-sdk/providers/openai-realtime). +## OpenAIRealtime2 + +OpenAI's **GA Realtime API** — separate engine marker because the GA endpoint speaks a different `session.update` wire shape (`output_modalities`, nested `audio.{input,output}` blocks, `session.type = "realtime"`) and rejects the legacy beta header. Targets `gpt-realtime-2` by default and routes through `OpenAIRealtime2Adapter`, which also handles bidirectional mulaw 8 kHz ↔ PCM 24 kHz transcoding (the GA audio engine silently drops mulaw frames). + +```typescript +// npx tsx example.ts +import { Patter, Twilio, OpenAIRealtime2 } from "getpatter"; + +const phone = new Patter({ carrier: new Twilio(), phoneNumber: "+15550001234" }); + +const agent = phone.agent({ + engine: new OpenAIRealtime2({ reasoningEffort: "low" }), // OPENAI_API_KEY from env + systemPrompt: "You are a helpful assistant.", + firstMessage: "Hello!", +}); + +await phone.serve({ agent }); +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `apiKey` | `string` | — | OpenAI API key. Reads from `OPENAI_API_KEY` when omitted. | +| `voice` | `string` | `"alloy"` | Voice preset. | +| `model` | `string` | `"gpt-realtime-2"` | GA Realtime model. | +| `reasoningEffort` | `"minimal" \| "low" \| "medium" \| "high"` | — | When omitted, the field is not sent and the server default applies. OpenAI recommends `"low"` for production voice. | +| `inputAudioTranscriptionModel` | `string` | — | Override for `audio.input.transcription.model`. Omit to keep `whisper-1`. | + + +The GA adapter pins `turn_detection.create_response: false` and `interrupt_response: false` in the `session.update` payload. Patter owns response creation (`response.create`) and barge-in cancellation explicitly so the hallucination filter and barge-in pipeline can decide per turn rather than letting the server VAD auto-trigger. See [`OpenAIRealtime2` — full reference](/typescript-sdk/providers/openai-realtime-2). + + ## ElevenLabsConvAI ElevenLabs Conversational AI — premium voice quality using a managed agent configured in the ElevenLabs dashboard. diff --git a/docs/typescript-sdk/local-mode.mdx b/docs/typescript-sdk/local-mode.mdx index 09036bfd..806518f7 100644 --- a/docs/typescript-sdk/local-mode.mdx +++ b/docs/typescript-sdk/local-mode.mdx @@ -78,13 +78,12 @@ Returns `{ "status": "ok", "mode": "local" }`. ## WebSocket Streams -Audio streams are handled over WebSocket: +Audio streams are handled over WebSocket. The server upgrades HTTP connections to WebSocket on the `/ws/stream/` path and each call gets its own connection. How `caller` and `callee` reach the handler depends on the carrier: -``` -wss://{webhookUrl}/ws/stream/{callId}?caller={caller}&callee={callee} -``` +- **Twilio** — the `wss://{webhookUrl}/ws/stream/{callId}` URL has no query string. Twilio strips query-string params during the WS upgrade handshake, so the inbound TwiML emits the caller / callee as `` / `` children of ``. The values are then surfaced in the WS `start` frame as `start.customParameters` and applied by `StreamHandler.handleCallStart`. +- **Telnyx** — `wss://{webhookUrl}/ws/stream/{callControlId}?caller={caller}&callee={callee}`. The Call Control flow includes the metadata in the answer command and the SDK reads the query string on the WS upgrade. -The server upgrades HTTP connections to WebSocket on the `/ws/stream/` path. Each call gets its own WebSocket connection. +If you construct the inbound TwiML yourself (rather than letting Patter's `/webhooks/twilio/voice` route emit it), use `TwilioAdapter.generateStreamTwiml(streamUrl, { caller, callee })` — the `parameters` argument is forwarded as `` children of `` so it lands on `start.customParameters`. ### Rate Limiting diff --git a/docs/typescript-sdk/mcp.mdx b/docs/typescript-sdk/mcp.mdx index 33b33a1a..7833d5cb 100644 --- a/docs/typescript-sdk/mcp.mdx +++ b/docs/typescript-sdk/mcp.mdx @@ -51,7 +51,7 @@ const agent = phone.agent({ ], }); -await phone.serve(agent, { port: 8000 }); +await phone.serve({ agent, port: 8000 }); ``` At call start you'll see a log line: @@ -154,7 +154,7 @@ const agent = phone.agent({ ], }); -await phone.serve(agent, { port: 8000 }); +await phone.serve({ agent, port: 8000 }); ``` ```python Python diff --git a/docs/typescript-sdk/metrics.mdx b/docs/typescript-sdk/metrics.mdx index 9e9cbc2e..adc6ca69 100644 --- a/docs/typescript-sdk/metrics.mdx +++ b/docs/typescript-sdk/metrics.mdx @@ -161,15 +161,14 @@ The most common case: pick a model on your adapter, and Patter bills the right r ```typescript TypeScript -import { Patter, Twilio } from "getpatter"; -import { OpenAIRealtimeAdapter, OpenAIRealtimeModel } from "getpatter"; +import { Patter, Twilio, OpenAIRealtime } from "getpatter"; -const agent = Patter.agent({ +const phone = new Patter({ carrier: new Twilio(), phoneNumber: "+15550001234" }); + +const agent = phone.agent({ systemPrompt: "You are a helpful assistant.", - realtime: new OpenAIRealtimeAdapter({ model: OpenAIRealtimeModel.GPT_REALTIME_2 }), + engine: new OpenAIRealtime({ model: "gpt-realtime-2" }), }); - -const phone = new Patter({ carrier: new Twilio(), phoneNumber: "+15550001234" }); // Billing auto-uses the gpt-realtime-2 rate ($32/M audio in, $64/M audio out). ``` diff --git a/docs/typescript-sdk/providers/anthropic.mdx b/docs/typescript-sdk/providers/anthropic.mdx index 8aac72db..1ee42e38 100644 --- a/docs/typescript-sdk/providers/anthropic.mdx +++ b/docs/typescript-sdk/providers/anthropic.mdx @@ -75,7 +75,7 @@ const agent = phone.agent({ firstMessage: "Hi, how can I help?", }); -await phone.serve(agent); +await phone.serve({ agent }); ``` ```python Python diff --git a/docs/typescript-sdk/providers/cerebras.mdx b/docs/typescript-sdk/providers/cerebras.mdx index 5eee4e88..cefb4ea8 100644 --- a/docs/typescript-sdk/providers/cerebras.mdx +++ b/docs/typescript-sdk/providers/cerebras.mdx @@ -86,7 +86,7 @@ const agent = phone.agent({ firstMessage: "Hi, how can I help?", }); -await phone.serve(agent); +await phone.serve({ agent }); ``` ```python Python diff --git a/docs/typescript-sdk/providers/elevenlabs-tts.mdx b/docs/typescript-sdk/providers/elevenlabs-tts.mdx index 4fb10e8b..2275a3d9 100644 --- a/docs/typescript-sdk/providers/elevenlabs-tts.mdx +++ b/docs/typescript-sdk/providers/elevenlabs-tts.mdx @@ -86,6 +86,18 @@ tts_telnyx = ElevenLabsTTS.for_telnyx(voice_id="rachel") `forTwilio` saves ~30–80 ms first-byte plus per-frame CPU and removes a potential aliasing source. If your Telnyx profile is pinned to PCMU/8000 instead, construct directly with `outputFormat: "ulaw_8000"`. +### Carrier auto-detect — `setTelephonyCarrier` + +When you don't know the carrier at construction time, `StreamHandler` calls `setTelephonyCarrier(carrier)` at call start to advise the provider of the wire format: + +```typescript +const tts = new ElevenLabsTTS(); // outputFormat defaults to pcm_16000 +tts.setTelephonyCarrier("twilio"); // auto-flips to ulaw_8000 +tts.setTelephonyCarrier("telnyx"); // keeps pcm_16000 (Telnyx default) +``` + +When `outputFormat` was passed explicitly to the constructor (or via `forTwilio` / `forTelnyx`), `setTelephonyCarrier` is a no-op — the user's choice always wins. Calling with an unknown carrier (`""` / `"custom"`) is also a no-op. + ## Constructor ```typescript diff --git a/docs/typescript-sdk/providers/google.mdx b/docs/typescript-sdk/providers/google.mdx index 341946a4..e927815f 100644 --- a/docs/typescript-sdk/providers/google.mdx +++ b/docs/typescript-sdk/providers/google.mdx @@ -82,7 +82,7 @@ const agent = phone.agent({ firstMessage: "Hi, how can I help?", }); -await phone.serve(agent); +await phone.serve({ agent }); ``` ```python Python diff --git a/docs/typescript-sdk/providers/groq.mdx b/docs/typescript-sdk/providers/groq.mdx index 61139eb7..80f2b453 100644 --- a/docs/typescript-sdk/providers/groq.mdx +++ b/docs/typescript-sdk/providers/groq.mdx @@ -83,7 +83,7 @@ const agent = phone.agent({ firstMessage: "Hi, how can I help?", }); -await phone.serve(agent); +await phone.serve({ agent }); ``` ```python Python diff --git a/docs/typescript-sdk/providers/openai-realtime-2.mdx b/docs/typescript-sdk/providers/openai-realtime-2.mdx new file mode 100644 index 00000000..fba067e8 --- /dev/null +++ b/docs/typescript-sdk/providers/openai-realtime-2.mdx @@ -0,0 +1,134 @@ +--- +title: "OpenAI Realtime 2 (GA)" +description: "GA Realtime API engine — separate adapter that speaks the new session.update wire shape required by gpt-realtime-2." +icon: "bolt" +--- + +# OpenAI Realtime 2 + +`OpenAIRealtime2` is the engine marker for OpenAI's **GA Realtime API** (the production endpoint that replaces the beta `OpenAI-Beta: realtime=v1` channel). It targets `gpt-realtime-2` by default and routes through `OpenAIRealtime2Adapter` — a dedicated adapter that speaks the GA `session.update` wire shape and performs bidirectional audio transcoding (mulaw 8 kHz ↔ PCM 24 kHz) required by the GA audio engine. + +For the legacy beta endpoint and the lower-cost `gpt-realtime-mini` model, keep using [`OpenAIRealtime`](/typescript-sdk/providers/openai-realtime). The two engines coexist — pick `OpenAIRealtime2` only when you specifically want the GA endpoint or the `gpt-realtime-2` model. + + +The GA endpoint rejects the legacy `OpenAI-Beta: realtime=v1` header and expects `output_modalities`, nested `audio.{input,output}` blocks with MIME-type strings, and `session.type = "realtime"`. These wire-shape differences are why GA needs its own adapter — the beta `OpenAIRealtimeAdapter` cannot reach `gpt-realtime-2` reliably. + + +## When to use + +| Use `OpenAIRealtime2` when… | Stick with `OpenAIRealtime` when… | +|----------------------------|-----------------------------------| +| You want `gpt-realtime-2` — strongest instruction following + 128K context + configurable `reasoningEffort`. | You're on `gpt-realtime-mini` for cost / latency reasons. | +| You're hitting the GA endpoint and the beta channel is being deprecated for your account. | You don't need the GA wire shape and want to keep the existing adapter path. | +| You want the bidirectional PCM 24 kHz transcoding handled by the SDK rather than the model silently dropping mulaw frames. | Your audio is already PCM 24 kHz end-to-end and beta works for you. | + +## Quickstart + +```typescript +import { Patter, Twilio, OpenAIRealtime2 } from "getpatter"; + +const phone = new Patter({ + carrier: new Twilio(), // TWILIO_* from env + phoneNumber: "+15555550100", +}); + +const agent = phone.agent({ + engine: new OpenAIRealtime2({ reasoningEffort: "low" }), + systemPrompt: "You are a friendly receptionist.", + firstMessage: "Hello! How can I help today?", +}); + +await phone.serve({ agent }); +``` + +`reasoningEffort: "low"` is OpenAI's recommended production tier for live voice — it gives the best instruction following without measurable per-turn latency. + +## Constructor + +```typescript +import { OpenAIRealtime2, type OpenAIRealtime2Options } from "getpatter"; + +new OpenAIRealtime2({ + apiKey?: string; // reads OPENAI_API_KEY + voice?: string; // default: "alloy" + model?: string; // default: "gpt-realtime-2" + reasoningEffort?: "minimal" | "low" | "medium" | "high"; + inputAudioTranscriptionModel?: string; // default: "whisper-1" +}); +``` + +All fields are optional with safe defaults. `apiKey` falls back to the `OPENAI_API_KEY` environment variable. + +### Reasoning effort + +| Value | When to use | +|-------|-------------| +| `"minimal"` | Snappy turn-taking. Skips most reasoning. | +| `"low"` | **Recommended for production voice.** Good instruction following without measurable per-turn latency. | +| `"medium"` | Multi-step tool flows where the model should plan. Adds latency. | +| `"high"` | Complex reasoning. Not recommended for live phone calls. | + +When set, Patter injects `session.reasoning = { effort: ... }` into the GA `session.update` payload. When omitted, the field is not sent and OpenAI's server default applies. + +### Streaming transcription + +Set `inputAudioTranscriptionModel` to override `audio.input.transcription.model`. The same identifiers as the beta endpoint apply — see the [streaming-transcription table on the OpenAI Realtime page](/typescript-sdk/providers/openai-realtime#streaming-transcription) for the full list (`whisper-1`, `gpt-4o-mini-transcribe`, `gpt-4o-transcribe`, `gpt-realtime-whisper`). + +## Audio path + +The GA audio engine speaks PCM 24 kHz and silently drops mulaw frames. Patter handles the conversion transparently inside `OpenAIRealtime2Adapter`: + +- **Inbound** (Twilio/Telnyx → model): mulaw 8 kHz → PCM 24 kHz +- **Outbound** (model → Twilio/Telnyx): PCM 24 kHz → mulaw 8 kHz + +No caller-side change is required — both Twilio Media Streams (mulaw 8 kHz) and Telnyx Call Control (PCM 16 kHz / mulaw 8 kHz) work out of the box. + +## Direct adapter use + +`OpenAIRealtime2Adapter` is exported and may be constructed directly when you need to share connection state across calls or override low-level fields. The constructor signature is **positional** (inherited from `OpenAIRealtimeAdapter`): + +```typescript +import { OpenAIRealtime2Adapter } from "getpatter"; + +const adapter = new OpenAIRealtime2Adapter( + process.env.OPENAI_API_KEY ?? "", // apiKey + "gpt-realtime-2", // model + "nova", // voice + "You are a helpful assistant.", // instructions + undefined, // tools + "g711_ulaw", // audioFormat — GA adapter emits PCM24 + // internally regardless of this value, + // but the positional arg is required. + { + reasoningEffort: "low", + inputAudioTranscriptionModel: "gpt-realtime-whisper", + }, +); + +const agent = phone.agent({ + engine: adapter, + systemPrompt: "...", + firstMessage: "...", +}); +``` + +The adapter extends `OpenAIRealtimeAdapter` and overrides `connect()`, `sendAudio()`, `receiveEvents()`, and `sendFirstMessage()` for the GA wire shape. + +### GA session config — `create_response: false` / `interrupt_response: false` + +The GA adapter unconditionally pins both flags in `session.update.turn_detection`. Patter owns response creation (`response.create`) and barge-in cancellation explicitly so the hallucination filter and barge-in pipeline can decide per turn rather than letting the server VAD auto-trigger. This is why the GA adapter is required — the legacy beta endpoint did not expose these knobs in the same shape. + +## Backward compatibility + +- Existing `new OpenAIRealtime({...})` callers are **unaffected**. The legacy engine continues to target the beta endpoint with `gpt-realtime-mini` as the default. +- `OpenAIRealtime2` ships as an additive engine — no migration required. Pick it when you want the GA endpoint; otherwise stay where you are. +- Pricing for `gpt-realtime-2` is auto-resolved per model from `DEFAULT_PRICING.openai_realtime.models["gpt-realtime-2"]` — see [Metrics](/typescript-sdk/metrics). + +## What's Next + + + The legacy engine for `gpt-realtime-mini` and earlier preview models. + All engine classes side by side. + Configure system prompts, tools, and first messages. + Function calling inside a Realtime session. + diff --git a/docs/typescript-sdk/providers/openai-realtime.mdx b/docs/typescript-sdk/providers/openai-realtime.mdx index 17e43b3a..8ce960f8 100644 --- a/docs/typescript-sdk/providers/openai-realtime.mdx +++ b/docs/typescript-sdk/providers/openai-realtime.mdx @@ -16,11 +16,11 @@ Pass any of these to `model:` on `new OpenAIRealtime(...)`. Pricing is auto-reso | Model | Audio in / out (per M tokens) | Notes | |-------|-------------------------------|-------| -| `"gpt-realtime-mini"` (default) | $10 / $20 | Fastest + cheapest. Production default for most voice flows. | +| `"gpt-4o-mini-realtime-preview"` (engine marker default) | $10 / $20 | Default when `new OpenAIRealtime()` is constructed without an explicit `model`. Earlier preview line. | +| `"gpt-realtime-mini"` | $10 / $20 | GA mini — recommended cheap default for new deployments. Pass `model: "gpt-realtime-mini"` explicitly. | | `"gpt-realtime"` | $32 / $64 | GA realtime model (Aug 2025). | -| `"gpt-realtime-2"` | $32 / $64 | Most-capable. Stronger instruction following, 128K context, supports `reasoningEffort`. | +| `"gpt-realtime-2"` | $32 / $64 | Most-capable. Stronger instruction following, 128K context, supports `reasoningEffort`. Use the [`OpenAIRealtime2`](/typescript-sdk/providers/openai-realtime-2) engine for the GA wire shape. | | `"gpt-4o-realtime-preview"` | $100 / $200 | Earlier preview, retained for compatibility. | -| `"gpt-4o-mini-realtime-preview"` | $10 / $20 | Earlier preview, retained for compatibility. | The same identifiers are exposed as a const object for editor autocomplete: @@ -123,9 +123,10 @@ The `reasoningEffort` and `inputAudioTranscriptionModel` options live on `OpenAI ## Backward compatibility -- Defaults are unchanged: `model: "gpt-realtime-mini"`, `inputAudioTranscriptionModel: "whisper-1"`, `reasoningEffort: undefined`. +- Engine marker default: `model: "gpt-4o-mini-realtime-preview"` (unchanged since first release). The underlying `OpenAIRealtimeAdapter` falls back to `gpt-realtime-mini` only when constructed without a model — the `OpenAIRealtime` wrapper always passes a model through, so the wire default is `gpt-4o-mini-realtime-preview`. Pass `model: "gpt-realtime-mini"` explicitly to upgrade. +- `inputAudioTranscriptionModel: "whisper-1"`, `reasoningEffort: undefined` (server default). - All existing `new OpenAIRealtime(...)` constructions keep working without code changes. -- Pricing for new models is added under `DEFAULT_PRICING.openai_realtime.models[...]`. The earlier `new Patter({ pricing: { openai_realtime: DEFAULT_PRICING.openai_realtime_2 } })` workaround is no longer needed — just construct with `model: "gpt-realtime-2"`. +- Pricing for new models is added under `DEFAULT_PRICING.openai_realtime.models[...]`. The earlier `new Patter({ pricing: { openai_realtime: DEFAULT_PRICING.openai_realtime_2 } })` workaround is no longer needed — just construct with `model: "gpt-realtime-2"` (or use the [`OpenAIRealtime2`](/typescript-sdk/providers/openai-realtime-2) engine for the GA wire shape). ## What's Next diff --git a/docs/typescript-sdk/providers/silero-vad.mdx b/docs/typescript-sdk/providers/silero-vad.mdx index 2097e22f..0452bdae 100644 --- a/docs/typescript-sdk/providers/silero-vad.mdx +++ b/docs/typescript-sdk/providers/silero-vad.mdx @@ -51,8 +51,8 @@ const vad = await SileroVAD.forPhoneCall(); // Or, full control: const vad2 = await SileroVAD.load({ - activationThreshold: 0.5, // Silero `threshold` - deactivationThreshold: 0.35, // `neg_threshold = threshold - 0.15` + activationThreshold: 0.8, // Silero `threshold`; 0.8 tuned for telephony + deactivationThreshold: 0.65, // `neg_threshold = threshold - 0.15` minSpeechDuration: 0.25, // seconds, `min_speech_duration_ms = 250` minSilenceDuration: 0.1, // seconds, `min_silence_duration_ms = 100` prefixPaddingDuration: 0.03, // seconds, `speech_pad_ms = 30` @@ -68,8 +68,8 @@ from getpatter.providers.silero_vad import SileroVAD vad = await asyncio.to_thread(SileroVAD.for_phone_call) vad2 = SileroVAD.load( - activation_threshold=0.5, - deactivation_threshold=0.35, + activation_threshold=0.8, + deactivation_threshold=0.65, min_speech_duration=0.25, min_silence_duration=0.1, prefix_padding_duration=0.03, @@ -81,15 +81,15 @@ vad2 = SileroVAD.load( ### Phone-call preset (`forPhoneCall`) -Identical to `load()` but pins `sampleRate` to 16000 Hz — the only sample rate Patter's pipeline-mode audio bus uses (8 kHz mulaw from Twilio is upsampled to 16 kHz PCM before reaching the VAD). All other parameters mirror the upstream Silero defaults from `snakers4/silero-vad`: +Identical to `load()` but pins `sampleRate` to 16000 Hz — the only sample rate Patter's pipeline-mode audio bus uses (8 kHz mulaw from Twilio is upsampled to 16 kHz PCM before reaching the VAD). Parameters are tuned for telephony-band audio (not the upstream Silero studio defaults): -- `activationThreshold = 0.5` — upstream `threshold` -- `deactivationThreshold = 0.35` — upstream `neg_threshold = threshold - 0.15` +- `activationThreshold = 0.8` — raised from upstream default 0.5 to filter background noise on telephony +- `deactivationThreshold = 0.65` — raised from upstream default 0.35 to match activation with no hysteresis gap - `minSpeechDuration = 0.25` — upstream `min_speech_duration_ms = 250` - `minSilenceDuration = 0.1` — upstream `min_silence_duration_ms = 100` - `prefixPaddingDuration = 0.03` — upstream `speech_pad_ms = 30` -Override any field via the options object. Deployments that experience truncation on natural pauses can raise `minSilenceDuration` (e.g. 0.5–1.0 s): +Override any field via the options object. Deployments that experience truncation on natural pauses can raise `minSilenceDuration` (e.g. 0.5–1.0 s). To restore the upstream Silero defaults for studio audio, pass `activationThreshold: 0.5, deactivationThreshold: 0.35`: ```typescript TypeScript diff --git a/docs/typescript-sdk/reference.mdx b/docs/typescript-sdk/reference.mdx index 84e95a56..ffecf5b6 100644 --- a/docs/typescript-sdk/reference.mdx +++ b/docs/typescript-sdk/reference.mdx @@ -23,6 +23,7 @@ new Patter(options: PatterOptions) | `webhookUrl` | `string` | Conditional | Public hostname, no scheme. Required unless using `tunnel: true` in `serve()`. | | `tunnel` | `CloudflareTunnel \| StaticTunnel \| boolean` | No | Tunnel directive. `true` is shorthand for `new CloudflareTunnel()`. | | `pricing` | `Record>` | No | Custom pricing overrides. | +| `persist` | `boolean \| string` | No | Persist the dashboard's call history to disk. Defaults to ON since 0.6.2 — pass `persist: false` to disable. See [Configuration](/typescript-sdk/configuration#persistent-dashboard-history). | ### Instance Methods @@ -68,7 +69,7 @@ class Telnyx { ## Engines ```typescript -import { OpenAIRealtime, ElevenLabsConvAI } from "getpatter"; +import { OpenAIRealtime, OpenAIRealtime2, ElevenLabsConvAI } from "getpatter"; ``` ### OpenAIRealtime @@ -79,10 +80,40 @@ class OpenAIRealtime { readonly apiKey: string; // reads OPENAI_API_KEY when omitted readonly voice: string; // default "alloy" readonly model: string; // default "gpt-4o-mini-realtime-preview" - constructor(opts?: { apiKey?: string; voice?: string; model?: string }); + readonly reasoningEffort?: "minimal" | "low" | "medium" | "high"; + readonly inputAudioTranscriptionModel?: string; + constructor(opts?: { + apiKey?: string; + voice?: string; + model?: string; + reasoningEffort?: "minimal" | "low" | "medium" | "high"; + inputAudioTranscriptionModel?: string; + }); } ``` +### OpenAIRealtime2 + +```typescript +class OpenAIRealtime2 { + readonly kind: "openai_realtime_2"; + readonly apiKey: string; // reads OPENAI_API_KEY when omitted + readonly voice: string; // default "alloy" + readonly model: string; // default "gpt-realtime-2" + readonly reasoningEffort?: "minimal" | "low" | "medium" | "high"; + readonly inputAudioTranscriptionModel?: string; + constructor(opts?: { + apiKey?: string; + voice?: string; + model?: string; + reasoningEffort?: "minimal" | "low" | "medium" | "high"; + inputAudioTranscriptionModel?: string; + }); +} +``` + +Routes through `OpenAIRealtime2Adapter` and speaks the GA `session.update` wire shape (`output_modalities`, nested `audio.{input,output}`, `session.type = "realtime"`). GA session config pins `turn_detection.create_response: false` and `interrupt_response: false` so Patter owns response creation and barge-in cancellation. See [`OpenAIRealtime2`](/typescript-sdk/providers/openai-realtime-2). + ### ElevenLabsConvAI ```typescript @@ -184,7 +215,7 @@ class Guardrail { ```typescript interface AgentOptions { systemPrompt: string; - engine?: OpenAIRealtime | ElevenLabsConvAI; + engine?: OpenAIRealtime | OpenAIRealtime2 | ElevenLabsConvAI; stt?: STTProvider; llm?: LLMProvider; tts?: TTSProvider; @@ -203,6 +234,7 @@ interface AgentOptions { bargeInThresholdMs?: number; aggressiveFirstFlush?: boolean; disablePhonePreamble?: boolean; + prewarmFirstMessage?: boolean; // default false; pipeline mode only provider?: 'openai_realtime' | 'elevenlabs_convai' | 'pipeline'; } ``` @@ -228,6 +260,7 @@ interface ServeOptions { dashboardToken?: string; dashboardDb?: string; dashboardPersist?: boolean; + manageWebhook?: boolean; // default true; ignored when tunnel: true } ``` @@ -347,7 +380,8 @@ export { Twilio, Telnyx, // Engines - OpenAIRealtime, ElevenLabsConvAI, + OpenAIRealtime, OpenAIRealtime2, ElevenLabsConvAI, + OpenAIRealtimeAdapter, OpenAIRealtime2Adapter, ElevenLabsConvAIAdapter, // STT classes DeepgramSTT, WhisperSTT, CartesiaSTT, AssemblyAISTT, SonioxSTT, diff --git a/docs/typescript-sdk/tts.mdx b/docs/typescript-sdk/tts.mdx index 8c19f17e..d3567f94 100644 --- a/docs/typescript-sdk/tts.mdx +++ b/docs/typescript-sdk/tts.mdx @@ -69,7 +69,7 @@ const tts3 = new ElevenLabsTTS({ apiKey: "...", voiceId: "EXAVITQu4vr4xnSDxMaL", | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `apiKey` | `string` | — | API key — reads from `ELEVENLABS_API_KEY` if omitted. | -| `voiceId` | `string` | `"EXAVITQu4vr4xnSDxMaL"` (Sarah) | ElevenLabs voice ID (or name). | +| `voiceId` | `string` | `"21m00Tcm4TlvDq8ikWAM"` (Rachel) | ElevenLabs voice ID (or name). | | `modelId` | `ElevenLabsModel \| string` | `"eleven_flash_v2_5"` | Typed literal: `eleven_flash_v2_5` / `eleven_turbo_v2_5` / `eleven_v3` / `eleven_multilingual_v2` / `eleven_monolingual_v1`. | | `outputFormat` | `string` | `"pcm_16000"` | ElevenLabs output format. | diff --git a/libraries/python/getpatter/__init__.py b/libraries/python/getpatter/__init__.py index 058319d9..f36a9402 100644 --- a/libraries/python/getpatter/__init__.py +++ b/libraries/python/getpatter/__init__.py @@ -19,7 +19,7 @@ See ``pyproject.toml`` and the top-level README for the full matrix. """ -__version__ = "0.6.1" +__version__ = "0.6.2" from getpatter._speech_events import ( AgentState, @@ -77,7 +77,9 @@ from getpatter.carriers.twilio import Carrier as Twilio from getpatter.carriers.telnyx import Carrier as Telnyx from getpatter.engines.openai import Realtime as OpenAIRealtime +from getpatter.engines.openai_realtime_2 import Realtime2 as OpenAIRealtime2 from getpatter.engines.elevenlabs import ConvAI as ElevenLabsConvAI +from getpatter.providers.openai_realtime_2 import OpenAIRealtime2Adapter # STT flat aliases — parity with libraries/typescript/src/index.ts. from getpatter.stt.deepgram import STT as DeepgramSTT @@ -407,6 +409,8 @@ def mix_pcm(agent: bytes, bg: bytes, ratio: float) -> bytes: "Twilio", "Telnyx", "OpenAIRealtime", + "OpenAIRealtime2", + "OpenAIRealtime2Adapter", "ElevenLabsConvAI", "DeepgramSTT", "WhisperSTT", diff --git a/libraries/python/getpatter/cli.py b/libraries/python/getpatter/cli.py index 7461e186..09ea2d7d 100644 --- a/libraries/python/getpatter/cli.py +++ b/libraries/python/getpatter/cli.py @@ -21,7 +21,10 @@ def main() -> None: help="Start the standalone call monitoring dashboard", ) dash.add_argument( - "--port", type=int, default=8000, help="Port to serve dashboard on (default: 8000)" + "--port", + type=int, + default=8000, + help="Port to serve dashboard on (default: 8000)", ) # patter eval run @@ -74,13 +77,23 @@ async def _run_dashboard(port: int) -> None: async def health(): return {"status": "ok", "mode": "dashboard"} - # Ingest endpoint — SDK POSTs completed call data here for live updates + # Ingest endpoint — SDK POSTs call lifecycle events here so a + # standalone dashboard surfaces them live. Three event kinds: + # * status="initiated" — outbound dial handed off to carrier, + # callee hasn't picked up yet. Surfaces the row immediately so + # the user sees the attempt during ringing. + # * default (no status) — call_start, media stream began. + # * ended_at present — call_end, final metrics + transcript. @app.post("/api/dashboard/ingest") async def ingest(request: Request): data = await request.json() call_id = data.get("call_id", "") if not call_id: return {"ok": False, "error": "missing call_id"} + status = data.get("status") + if status == "initiated": + store.record_call_initiated(data) + return {"ok": True, "call_id": call_id, "event": "initiated"} store.record_call_start(data) if data.get("ended_at"): store.record_call_end(data, metrics=data.get("metrics")) diff --git a/libraries/python/getpatter/client.py b/libraries/python/getpatter/client.py index 32b942f1..0d4f7dc9 100644 --- a/libraries/python/getpatter/client.py +++ b/libraries/python/getpatter/client.py @@ -19,6 +19,7 @@ import asyncio import logging +import os import time from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any @@ -71,9 +72,12 @@ def _resolve_persist_root(persist: bool | str | None) -> str | None: - ``persist is False`` → ``None`` (force off, even if env var is set) - ``persist is True`` → platform default (``resolve_log_root("auto")``) - ``persist`` is a string → exactly that path (after ``~`` expansion) - - ``persist is None`` → fall back to ``PATTER_LOG_DIR`` env var, or - ``None`` if the env is also unset (preserves the prior opt-in - behaviour where persistence required setting the env explicitly) + - ``persist is None`` → ``PATTER_LOG_DIR`` env var if set, else platform + default (``resolve_log_root("auto")``). Changed from the prior + opt-in behaviour on 2026-05-21: the dashboard's hydrate path + requires on-disk records to survive process restarts, so persistence + now defaults to ON. Set ``persist=False`` to keep the old + ephemeral-RAM-only behaviour. """ from getpatter.services.call_log import resolve_log_root @@ -86,6 +90,11 @@ def _resolve_persist_root(persist: bool | str | None) -> str | None: result = resolve_log_root(persist) return str(result) if result is not None else None result = resolve_log_root() + if result is not None: + return str(result) + # No explicit persist + no env var → fall back to platform default so + # the dashboard hydrate path always has something to read. + result = resolve_log_root("auto") return str(result) if result is not None else None @@ -135,7 +144,15 @@ async def _safe_close_handle(handle: Any) -> None: if ws is not None: await ws.close() return - # Bare websocket + # Bare websocket — may have a parked keepalive task attached by + # the GA Realtime parker. Cancel it before closing so the loop + # doesn't race the close handshake with another send(). + ka = getattr(handle, "_parked_keepalive_task", None) + if ka is not None: + try: + ka.cancel() + except Exception: + pass await handle.close() except Exception: pass @@ -624,39 +641,41 @@ async def call( # penalty on human pickups — the call connects immediately # and the result arrives via the ``/webhooks/twilio/amd`` # callback. Twilio best-practice default. - extra_params["MachineDetection"] = "DetectMessageEnd" - extra_params["AsyncAmd"] = "true" - extra_params["AsyncAmdStatusCallback"] = ( + # + # NOTE: All keys here MUST be snake_case. The twilio-python + # SDK's ``client.calls.create(**kwargs)`` accepts snake_case + # arguments and internally translates them to the PascalCase + # form Twilio's REST API requires on the wire. Passing + # ``MachineDetection`` / ``StatusCallback`` etc. directly to + # ``calls.create`` raises ``TypeError: unexpected keyword + # argument`` and crashes every outbound call (the bug that + # shipped through 0.5.x and was reported externally). + extra_params["machine_detection"] = "DetectMessageEnd" + extra_params["async_amd"] = "true" + extra_params["async_amd_status_callback"] = ( f"https://{config.webhook_url}/webhooks/twilio/amd" ) if ring_timeout is not None: - extra_params["Timeout"] = int(ring_timeout) + extra_params["timeout"] = int(ring_timeout) # Status callback so the dashboard sees ringing/failed/ # no-answer transitions before any media webhook fires. extra_params.setdefault( - "StatusCallback", + "status_callback", f"https://{config.webhook_url}/webhooks/twilio/status", ) - extra_params.setdefault("StatusCallbackMethod", "POST") - # ``StatusCallbackEvent`` must be a list (twilio-python + extra_params.setdefault("status_callback_method", "POST") + # ``status_callback_event`` must be a list (twilio-python # serialises it as repeated query params), NOT a - # space-separated single string. Pass via the snake_case key - # ``status_callback_event`` that the twilio-python SDK - # documents — the space-separated form triggered Twilio - # notification 21626 ("invalid statusCallbackEvents") and on - # some ingestion paths also broke the answer-handler webhook - # (root cause of intermittent 11100 WS-upgrade failures). + # space-separated single string. The space-separated form + # triggered Twilio notification 21626 ("invalid + # statusCallbackEvents") and on some ingestion paths also + # broke the answer-handler webhook (root cause of intermittent + # 11100 WS-upgrade failures). # See https://www.twilio.com/docs/voice/api/call-resource#statuscallbackevent - if ( - "StatusCallbackEvent" not in extra_params - and "status_callback_event" not in extra_params - ): - extra_params["status_callback_event"] = [ - "initiated", - "ringing", - "answered", - "completed", - ] + extra_params.setdefault( + "status_callback_event", + ["initiated", "ringing", "answered", "completed"], + ) call_id = await adapter.initiate_call( config.phone_number or from_number, to, @@ -666,28 +685,41 @@ async def call( logger.info("Outbound call initiated: %s", call_id) # Pre-register the call so the dashboard surfaces attempts # that never reach media (no-answer, busy, carrier-reject). + initiated_payload = { + "call_id": call_id, + "caller": config.phone_number or from_number, + "callee": to, + "direction": "outbound", + "status": "initiated", + } if ( self._server is not None and getattr(self._server, "_metrics_store", None) is not None ): try: - self._server._metrics_store.record_call_initiated( - { - "call_id": call_id, - "caller": config.phone_number or from_number, - "callee": to, - "direction": "outbound", - } - ) + self._server._metrics_store.record_call_initiated(initiated_payload) except Exception as exc: logger.debug("record_call_initiated: %s", exc) - self._spawn_prewarm_first_message(agent, call_id, ring_timeout=ring_timeout) + # Relay to a standalone dashboard (``patter dashboard`` running + # in a separate process) so it surfaces the dial attempt the + # moment we hand off to the carrier, not only when media arrives + # on pickup. Fire-and-forget — silent when no standalone + # dashboard is listening. + try: + from getpatter.dashboard.persistence import notify_dashboard + + asyncio.create_task(notify_dashboard(initiated_payload)) + except Exception: + pass + self._spawn_prewarm_first_message( + agent, call_id, ring_timeout=ring_timeout, carrier="twilio" + ) # Park provider WebSockets in parallel so the per-call # StreamHandler can adopt them at ``start`` instead of # paying the cold-handshake on first turn. Off when the # user explicitly sets ``agent.prewarm=False``. if getattr(agent, "prewarm", True) is not False: - self._park_provider_connections(agent, call_id) + self._park_provider_connections(agent, call_id, carrier="twilio") elif config.telephony_provider == "telnyx": from getpatter.providers.telnyx_adapter import TelnyxAdapter # type: ignore[import] @@ -704,28 +736,36 @@ async def call( machine_detection=wants_amd, ) logger.info("Outbound call initiated: %s", call_id) + initiated_payload = { + "call_id": call_id, + "caller": config.phone_number or from_number, + "callee": to, + "direction": "outbound", + "status": "initiated", + } if ( self._server is not None and getattr(self._server, "_metrics_store", None) is not None ): try: - self._server._metrics_store.record_call_initiated( - { - "call_id": call_id, - "caller": config.phone_number or from_number, - "callee": to, - "direction": "outbound", - } - ) + self._server._metrics_store.record_call_initiated(initiated_payload) except Exception as exc: logger.debug("record_call_initiated: %s", exc) - self._spawn_prewarm_first_message(agent, call_id, ring_timeout=ring_timeout) + try: + from getpatter.dashboard.persistence import notify_dashboard + + asyncio.create_task(notify_dashboard(initiated_payload)) + except Exception: + pass + self._spawn_prewarm_first_message( + agent, call_id, ring_timeout=ring_timeout, carrier="telnyx" + ) # Park provider WebSockets in parallel so the per-call # StreamHandler can adopt them at ``start`` instead of # paying the cold-handshake on first turn. Off when the # user explicitly sets ``agent.prewarm=False``. if getattr(agent, "prewarm", True) is not False: - self._park_provider_connections(agent, call_id) + self._park_provider_connections(agent, call_id, carrier="telnyx") # === Pre-warm helpers === @@ -802,7 +842,13 @@ def close_prewarmed_connections(self, call_id: str) -> None: if slot is not None: _close_parked_slot(slot) - def _park_provider_connections(self, agent: Agent, call_id: str) -> None: + def _park_provider_connections( + self, + agent: Agent, + call_id: str, + *, + carrier: str | None = None, + ) -> None: """Open and park provider WebSockets in parallel with the carrier-side ``initiate_call``. Unlike :meth:`_spawn_provider_warmup` (which closes the WS after a brief idle), the sockets opened here @@ -824,7 +870,9 @@ def _park_provider_connections(self, agent: Agent, call_id: str) -> None: tts = getattr(agent, "tts", None) stt_open = getattr(stt, "open_parked_connection", None) if stt else None tts_open = getattr(tts, "open_parked_connection", None) if tts else None - if stt_open is None and tts_open is None: + provider = getattr(agent, "provider", None) + wants_realtime_park = provider in ("openai_realtime", "openai_realtime_2") + if stt_open is None and tts_open is None and not wants_realtime_park: return slot: dict[str, Any] = {} @@ -867,8 +915,93 @@ async def _park_tts() -> None: except Exception as exc: # noqa: BLE001 - best-effort logger.debug("Park TTS failed for %s: %s", call_id, exc) + async def _park_openai_realtime() -> None: + if not wants_realtime_park: + return + # Build a throw-away adapter instance JUST to call + # ``open_parked_connection`` and produce a primed WS. The + # per-call StreamHandler builds its own adapter and adopts + # the returned WS via ``adopt_websocket``. Constructed with + # the same agent-derived kwargs the StreamHandler would use, + # so the parked session.update matches what the live session + # expects — no second session.update round-trip on adopt. + from getpatter.providers.openai_realtime_2 import ( # type: ignore[import] + OpenAIRealtime2Adapter, + ) + + # The OpenAI key lives on ``LocalConfig.openai_key`` (set by + # the user when constructing ``Patter()``); fall back to + # ``OPENAI_API_KEY`` env var when not explicitly configured. + api_key = getattr(self._local_config, "openai_key", None) or os.environ.get( + "OPENAI_API_KEY" + ) + if not api_key: + logger.info( + "[PREWARM] callId=%s provider=openai_realtime SKIPPED — " + "no OPENAI_API_KEY available", + call_id, + ) + return + try: + adapter_kwargs: dict[str, Any] = { + "api_key": api_key, + "model": agent.model, + "voice": agent.voice, + "instructions": agent.system_prompt or "", + "language": agent.language, + "tools": [], + # Carrier-derived placeholder; the GA adapter's session + # always emits ``audio/pcm @ 24000`` regardless of this + # value (it transcodes mulaw↔pcm internally), so any + # non-None value keeps the parent class happy. + "audio_format": "g711_ulaw" if carrier == "twilio" else "pcm16", + } + reasoning_effort = getattr( + agent, "openai_realtime_reasoning_effort", None + ) + if reasoning_effort is not None: + adapter_kwargs["reasoning_effort"] = reasoning_effort + transcription_model = getattr( + agent, + "openai_realtime_input_audio_transcription_model", + None, + ) + if transcription_model is not None: + adapter_kwargs["input_audio_transcription_model"] = ( + transcription_model + ) + tmp_adapter = OpenAIRealtime2Adapter(**adapter_kwargs) + ws = await tmp_adapter.open_parked_connection() + if self._prewarmed_connections.get(call_id) is not slot: + try: + await ws.close() + except Exception: + pass + return + slot["openai_realtime"] = ws + logger.info( + "[PREWARM] callId=%s provider=openai_realtime ms=%d", + call_id, + int((time.monotonic() - started_at) * 1000), + ) + except Exception as exc: # noqa: BLE001 - best-effort + # Bumped to INFO so prewarm failures surface in normal + # logs — they're best-effort but invisible failures make + # the latency optimisation hard to debug. Callers can + # silence with a logging filter if they really want. + logger.info( + "[PREWARM] callId=%s provider=openai_realtime FAILED: %s", + call_id, + exc, + ) + async def _run_all() -> None: - await asyncio.gather(_park_stt(), _park_tts(), return_exceptions=True) + await asyncio.gather( + _park_stt(), + _park_tts(), + _park_openai_realtime(), + return_exceptions=True, + ) task = asyncio.create_task(_run_all()) self._prewarm_tasks.add(task) @@ -915,7 +1048,12 @@ async def _evict_parked_after(self, call_id: str, ttl_s: float) -> None: ) def _spawn_prewarm_first_message( - self, agent: Agent, call_id: str, *, ring_timeout: int | None + self, + agent: Agent, + call_id: str, + *, + ring_timeout: int | None, + carrier: str | None = None, ) -> None: """Pre-render ``agent.first_message`` to TTS bytes during the ringing window and stash them in ``_prewarm_audio[call_id]``. @@ -935,6 +1073,12 @@ def _spawn_prewarm_first_message( **Capped at ``_PREWARM_CACHE_MAX`` concurrent entries.** Refused with a WARN when the cap is reached (the call still proceeds — StreamHandler falls back to live TTS). + + ``carrier`` — when provided (``"twilio"`` / ``"telnyx"``), the TTS + adapter's ``set_telephony_carrier`` hook is called BEFORE synthesis + so it can produce wire-native bytes (``ulaw_8000`` for Twilio, + ``pcm_16000`` for Telnyx) and skip the client-side transcode. + Parity with TS ``Patter.spawnPrewarmFirstMessage(carrier)``. """ if not getattr(agent, "prewarm_first_message", False): return @@ -957,6 +1101,26 @@ def _spawn_prewarm_first_message( if synthesize is None or not callable(synthesize): return + # Advise the TTS adapter of the telephony carrier BEFORE we trigger + # the synth so it can produce wire-native bytes (``ulaw_8000`` for + # Twilio, ``pcm_16000`` for Telnyx) — skipping the client-side + # resample + mulaw encode that produced audible artifacts on the + # prewarmed firstMessage during 0.6.2 acceptance. The hook is opt-in + # per-adapter; adapters that don't expose it (or that the user + # configured with an explicit output_format) keep their format. + # Parity with TS ``Patter.spawnPrewarmFirstMessage``. + if carrier: + set_carrier = getattr(tts, "set_telephony_carrier", None) + if callable(set_carrier): + try: + set_carrier(carrier) + except Exception as _exc: + logger.debug( + "Prewarm TTS set_telephony_carrier failed for %s: %s", + call_id, + _exc, + ) + # FIX #96 — refuse to spawn when the cache (live entries + # in-flight synth tasks) would exceed the cap. Counting both # active entries AND pending tasks keeps the bound honest under @@ -1102,7 +1266,7 @@ def agent( self, system_prompt: str, voice: str = "alloy", - model: str = "gpt-4o-mini-realtime-preview", + model: str = "gpt-realtime-mini", language: str = "en", first_message: str = "", tools: list[Tool] | None = None, @@ -1122,6 +1286,7 @@ def agent( engine: Any = None, llm: LLMProvider | None = None, mcp_servers: list | None = None, + prewarm_first_message: bool | None = None, ) -> Agent: """Create an ``Agent`` configuration. @@ -1179,9 +1344,9 @@ def agent( # users sometimes pass the engine AND a specific voice. if voice == "alloy" and engine_fields.get("voice"): voice = engine_fields["voice"] - if model == "gpt-4o-mini-realtime-preview" and engine_fields.get("model"): + if model == "gpt-realtime-mini" and engine_fields.get("model"): model = engine_fields["model"] - if engine_kind == "openai_realtime": + if engine_kind in ("openai_realtime", "openai_realtime_2"): openai_engine_key = engine_fields.get("api_key", "") openai_realtime_reasoning_effort = engine_fields.get("reasoning_effort") openai_realtime_input_audio_transcription_model = engine_fields.get( @@ -1212,7 +1377,10 @@ def agent( self._local_config, elevenlabs_key=elevenlabs_engine_key ) - if provider == "openai_realtime" and not self._local_config.openai_key: + if ( + provider in ("openai_realtime", "openai_realtime_2") + and not self._local_config.openai_key + ): raise ValueError( "OpenAI Realtime mode requires an OpenAI API key. Pass " "engine=OpenAIRealtime(api_key='sk-...') or set OPENAI_API_KEY " @@ -1262,6 +1430,20 @@ def agent( self._guardrail_to_dict(g, index=i) for i, g in enumerate(guardrails) ] + # ``prewarm_first_message`` is opt-in (default False) — reverted + # from 2026-05-18's default-on attempt after the 0.6.2 acceptance + # run surfaced a phantom-barge-in interaction: prewarm bursts + # audio at pickup, the very first inbound carrier frame triggered + # Silero VAD speech_start, the firstMessage was cancelled + # mid-playback and the user heard a clipped (graffiante) fragment. + # Until the root cause (anchoring the barge-in gate on + # first-mark-echo rather than ``first_audio_sent_at = begin_speaking + # time``) is fully addressed, default it off so most pipeline calls + # take the live-streaming path that the user is happy with. Opt in + # explicitly per agent when willing to pay the trade-off. + if prewarm_first_message is None: + prewarm_first_message = False + return Agent( system_prompt=system_prompt, voice=voice, @@ -1285,6 +1467,7 @@ def agent( echo_cancellation=echo_cancellation, llm=llm, mcp_servers=mcp_servers, + prewarm_first_message=prewarm_first_message, openai_realtime_reasoning_effort=openai_realtime_reasoning_effort, openai_realtime_input_audio_transcription_model=openai_realtime_input_audio_transcription_model, ) @@ -1294,7 +1477,16 @@ def _unpack_engine(engine: Any) -> tuple[str, dict]: """Convert an engine instance to ``(kind, {voice, model, api_key, agent_id})``.""" from getpatter.engines.elevenlabs import ConvAI as _ConvAI from getpatter.engines.openai import Realtime as _Realtime + from getpatter.engines.openai_realtime_2 import Realtime2 as _Realtime2 + if isinstance(engine, _Realtime2): + return "openai_realtime_2", { + "api_key": engine.api_key, + "voice": engine.voice, + "model": engine.model, + "reasoning_effort": engine.reasoning_effort, + "input_audio_transcription_model": engine.input_audio_transcription_model, + } if isinstance(engine, _Realtime): return "openai_realtime", { "api_key": engine.api_key, @@ -1310,8 +1502,8 @@ def _unpack_engine(engine: Any) -> tuple[str, dict]: "voice": engine.voice, } raise TypeError( - "engine= must be an OpenAIRealtime(...) or ElevenLabsConvAI(...) " - f"instance, got {type(engine).__name__}" + "engine= must be an OpenAIRealtime(...), OpenAIRealtime2(...), or " + f"ElevenLabsConvAI(...) instance, got {type(engine).__name__}" ) @staticmethod diff --git a/libraries/python/getpatter/dashboard/store.py b/libraries/python/getpatter/dashboard/store.py index 634f377c..61a2bc20 100644 --- a/libraries/python/getpatter/dashboard/store.py +++ b/libraries/python/getpatter/dashboard/store.py @@ -5,9 +5,12 @@ __all__ = ["MetricsStore", "MetricsStoreProtocol"] import asyncio +import json import threading import time from dataclasses import asdict +from datetime import datetime +from pathlib import Path from typing import Any, Protocol, runtime_checkable from getpatter.models import CallMetrics @@ -277,10 +280,36 @@ def record_call_end( existing = self._calls[idx] break + # Resolve the final transcript and turns. ``data["transcript"]`` + # from the SDK is the authoritative ``conversation_history`` + # snapshot at hang-up; when it's missing or empty (e.g. + # webhook-rejected inbound, Realtime adopted path where + # ``conversation.item.input_audio_transcription.completed`` raced + # a ``response.cancel`` and never fired, or the active record + # was already moved to ``self._calls`` by an earlier + # statusCallback), fall back to the running transcript / + # turns we accumulated on the active record via ``record_turn``. + # This keeps the live-transcript pane stable across the + # ``call_status (completed)`` → ``call_end`` gap, and matches + # the TS parity (``dashboard/store.ts`` resolvedTranscript / + # resolvedTurns). See dashboard BUG D. + data_transcript = data.get("transcript") or [] + resolved_transcript: list[Any] + if data_transcript: + resolved_transcript = list(data_transcript) + elif active is not None and active.get("transcript"): + resolved_transcript = list(active["transcript"]) + elif existing is not None and existing.get("transcript"): + resolved_transcript = list(existing["transcript"]) + else: + resolved_transcript = [] + source_for_turns = active or existing or {} + preserved_turns = list(source_for_turns.get("turns") or []) entry: dict[str, Any] = { "call_id": call_id, "ended_at": time.time(), - "transcript": data.get("transcript", []), + "transcript": resolved_transcript, + "turns": preserved_turns, } source = active or existing if source: @@ -498,6 +527,7 @@ def get_aggregates(self) -> dict[str, Any]: "telephony": 0.0, }, "active_calls": len(self._active_calls), + "sdk_version": _sdk_version(), } total_cost = 0.0 @@ -543,6 +573,7 @@ def get_aggregates(self) -> dict[str, Any]: "telephony": round(cost_tel, 6), }, "active_calls": len(self._active_calls), + "sdk_version": _sdk_version(), } def get_calls_in_range( @@ -663,6 +694,18 @@ def hydrate(self, log_root: str | None) -> int: meta_path, ) continue + # Backfill transcript from sibling ``transcript.jsonl`` + # when ``metadata.json`` doesn't carry the flat + # transcript array (CallLogger writes one turn per + # line; ``metadata.json`` only carries the aggregate + # count). Without this, hydrated past calls render + # with an empty transcript pane on click. Parity + # with TS ``loadTranscriptJsonl`` (store.ts:780). + if not record.get("transcript"): + jsonl_path = call_dir / "transcript.jsonl" + from_jsonl = _load_transcript_jsonl(jsonl_path) + if from_jsonl: + record["transcript"] = from_jsonl collected.append(record) seen.add(call_id) @@ -786,3 +829,81 @@ def _to_seconds(raw: Any) -> float | None: if ended is not None: record["ended_at"] = ended return record + + +def _sdk_version() -> str: + """Resolve the installed ``getpatter`` package version at runtime. + + Single source of truth: ``getpatter.__version__``. Surfaced via the + dashboard ``/api/dashboard/aggregates`` payload so the SPA top-bar + pill / footer always tracks the package version that's actually + serving the dashboard — no manual sync needed when bumping versions. + """ + try: + from getpatter import __version__ + + return str(__version__) + except Exception: + return "" + + +def _load_transcript_jsonl(file_path: Path) -> list[dict[str, Any]]: + """Reconstruct a flat ``[{role, text, timestamp}, ...]`` transcript + array from a per-call ``transcript.jsonl`` file written by + ``CallLogger.log_turn``. Parity with TS ``loadTranscriptJsonl`` + (libraries/typescript/src/dashboard/store.ts:780). + + Each JSONL line carries ``user_text`` / ``agent_text`` plus a + timestamp (``ts`` ISO-8601 or ``timestamp`` numeric seconds). Splits + the row into one or two entries so the dashboard's transcript pane + renders user + assistant turns interleaved. Filters the + ``[interrupted]`` placeholder agent text (cancelled barge-in turns). + """ + if not file_path.is_file(): + return [] + out: list[dict[str, Any]] = [] + try: + with open(file_path, encoding="utf-8") as fh: + for raw_line in fh: + line = raw_line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(row, dict): + continue + ts_iso = row.get("ts") + ts_num = row.get("timestamp") + timestamp: float = 0.0 + if isinstance(ts_iso, str): + try: + timestamp = datetime.fromisoformat( + ts_iso.replace("Z", "+00:00") + ).timestamp() + except ValueError: + timestamp = 0.0 + if timestamp == 0.0 and isinstance(ts_num, (int, float)): + timestamp = float(ts_num) + user_text = row.get("user_text") or "" + agent_text = row.get("agent_text") or "" + if isinstance(user_text, str) and user_text: + out.append( + {"role": "user", "text": user_text, "timestamp": timestamp} + ) + if ( + isinstance(agent_text, str) + and agent_text + and agent_text != "[interrupted]" + ): + out.append( + { + "role": "assistant", + "text": agent_text, + "timestamp": timestamp, + } + ) + except OSError: + return out + return out diff --git a/libraries/python/getpatter/dashboard/ui.html b/libraries/python/getpatter/dashboard/ui.html index 50347d38..29a02214 100644 --- a/libraries/python/getpatter/dashboard/ui.html +++ b/libraries/python/getpatter/dashboard/ui.html @@ -15,7 +15,7 @@ href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" /> - +`+s.stack}return{value:e,source:t,stack:l,digest:null}}function ts(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function Us(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var $d=typeof WeakMap=="function"?WeakMap:Map;function Ja(e,t,n){n=be(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){cl||(cl=!0,Js=r),Us(e,t)},n}function qa(e,t,n){n=be(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){Us(e,t)}}var s=e.stateNode;return s!==null&&typeof s.componentDidCatch=="function"&&(n.callback=function(){Us(e,t),typeof r!="function"&&(yt===null?yt=new Set([this]):yt.add(this));var o=t.stack;this.componentDidCatch(t.value,{componentStack:o!==null?o:""})}),n}function Hi(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new $d;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=bd.bind(null,e,t,n),t.then(e,e))}function Bi(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function Wi(e,t,n,r,l){return e.mode&1?(e.flags|=65536,e.lanes=l,e):(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=be(-1,1),t.tag=2,vt(n,t,1))),n.lanes|=1),e)}var Vd=lt.ReactCurrentOwner,me=!1;function ue(e,t,n,r){t.child=e===null?Ea(t,null,n,r):pn(t,e.child,n,r)}function Qi(e,t,n,r,l){n=n.render;var s=t.ref;return un(t,l),r=Io(e,t,n,r,s,l),n=Ao(),e!==null&&!me?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,rt(e,t,l)):(B&&n&&Co(t),t.flags|=1,ue(e,t,r,l),t.child)}function Ki(e,t,n,r,l){if(e===null){var s=n.type;return typeof s=="function"&&!Ko(s)&&s.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=s,ba(e,t,s,r,l)):(e=Br(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(s=e.child,!(e.lanes&l)){var o=s.memoizedProps;if(n=n.compare,n=n!==null?n:Gn,n(o,r)&&e.ref===t.ref)return rt(e,t,l)}return t.flags|=1,e=wt(s,r),e.ref=t.ref,e.return=t,t.child=e}function ba(e,t,n,r,l){if(e!==null){var s=e.memoizedProps;if(Gn(s,r)&&e.ref===t.ref)if(me=!1,t.pendingProps=r=s,(e.lanes&l)!==0)e.flags&131072&&(me=!0);else return t.lanes=e.lanes,rt(e,t,l)}return Hs(e,t,n,r,l)}function ec(e,t,n){var r=t.pendingProps,l=r.children,s=e!==null?e.memoizedState:null;if(r.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},F(nn,we),we|=n;else{if(!(n&1073741824))return e=s!==null?s.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,F(nn,we),we|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=s!==null?s.baseLanes:n,F(nn,we),we|=r}else s!==null?(r=s.baseLanes|n,t.memoizedState=null):r=n,F(nn,we),we|=r;return ue(e,t,l,n),t.child}function tc(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function Hs(e,t,n,r,l){var s=ye(n)?Dt:ie.current;return s=fn(t,s),un(t,l),n=Io(e,t,n,r,s,l),r=Ao(),e!==null&&!me?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,rt(e,t,l)):(B&&r&&Co(t),t.flags|=1,ue(e,t,n,l),t.child)}function Yi(e,t,n,r,l){if(ye(n)){var s=!0;el(t)}else s=!1;if(un(t,l),t.stateNode===null)Vr(e,t),Za(t,n,r),Vs(t,n,r,l),r=!0;else if(e===null){var o=t.stateNode,u=t.memoizedProps;o.props=u;var a=o.context,f=n.contextType;typeof f=="object"&&f!==null?f=Le(f):(f=ye(n)?Dt:ie.current,f=fn(t,f));var h=n.getDerivedStateFromProps,v=typeof h=="function"||typeof o.getSnapshotBeforeUpdate=="function";v||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(u!==r||a!==f)&&Ui(t,o,r,f),it=!1;var m=t.memoizedState;o.state=m,sl(t,r,o,l),a=t.memoizedState,u!==r||m!==a||ve.current||it?(typeof h=="function"&&($s(t,n,h,r),a=t.memoizedState),(u=it||Vi(t,n,u,r,m,a,f))?(v||typeof o.UNSAFE_componentWillMount!="function"&&typeof o.componentWillMount!="function"||(typeof o.componentWillMount=="function"&&o.componentWillMount(),typeof o.UNSAFE_componentWillMount=="function"&&o.UNSAFE_componentWillMount()),typeof o.componentDidMount=="function"&&(t.flags|=4194308)):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=a),o.props=r,o.state=a,o.context=f,r=u):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{o=t.stateNode,La(e,t),u=t.memoizedProps,f=t.type===t.elementType?u:ze(t.type,u),o.props=f,v=t.pendingProps,m=o.context,a=n.contextType,typeof a=="object"&&a!==null?a=Le(a):(a=ye(n)?Dt:ie.current,a=fn(t,a));var x=n.getDerivedStateFromProps;(h=typeof x=="function"||typeof o.getSnapshotBeforeUpdate=="function")||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(u!==v||m!==a)&&Ui(t,o,r,a),it=!1,m=t.memoizedState,o.state=m,sl(t,r,o,l);var w=t.memoizedState;u!==v||m!==w||ve.current||it?(typeof x=="function"&&($s(t,n,x,r),w=t.memoizedState),(f=it||Vi(t,n,f,r,m,w,a)||!1)?(h||typeof o.UNSAFE_componentWillUpdate!="function"&&typeof o.componentWillUpdate!="function"||(typeof o.componentWillUpdate=="function"&&o.componentWillUpdate(r,w,a),typeof o.UNSAFE_componentWillUpdate=="function"&&o.UNSAFE_componentWillUpdate(r,w,a)),typeof o.componentDidUpdate=="function"&&(t.flags|=4),typeof o.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof o.componentDidUpdate!="function"||u===e.memoizedProps&&m===e.memoizedState||(t.flags|=4),typeof o.getSnapshotBeforeUpdate!="function"||u===e.memoizedProps&&m===e.memoizedState||(t.flags|=1024),t.memoizedProps=r,t.memoizedState=w),o.props=r,o.state=w,o.context=a,r=f):(typeof o.componentDidUpdate!="function"||u===e.memoizedProps&&m===e.memoizedState||(t.flags|=4),typeof o.getSnapshotBeforeUpdate!="function"||u===e.memoizedProps&&m===e.memoizedState||(t.flags|=1024),r=!1)}return Bs(e,t,n,r,s,l)}function Bs(e,t,n,r,l,s){tc(e,t);var o=(t.flags&128)!==0;if(!r&&!o)return l&&zi(t,n,!1),rt(e,t,s);r=t.stateNode,Vd.current=t;var u=o&&typeof n.getDerivedStateFromError!="function"?null:r.render();return t.flags|=1,e!==null&&o?(t.child=pn(t,e.child,null,s),t.child=pn(t,null,u,s)):ue(e,t,u,s),t.memoizedState=r.state,l&&zi(t,n,!0),t.child}function nc(e){var t=e.stateNode;t.pendingContext?Ti(e,t.pendingContext,t.pendingContext!==t.context):t.context&&Ti(e,t.context,!1),To(e,t.containerInfo)}function Xi(e,t,n,r,l){return dn(),_o(l),t.flags|=256,ue(e,t,n,r),t.child}var Ws={dehydrated:null,treeContext:null,retryLane:0};function Qs(e){return{baseLanes:e,cachePool:null,transitions:null}}function rc(e,t,n){var r=t.pendingProps,l=Q.current,s=!1,o=(t.flags&128)!==0,u;if((u=o)||(u=e!==null&&e.memoizedState===null?!1:(l&2)!==0),u?(s=!0,t.flags&=-129):(e===null||e.memoizedState!==null)&&(l|=1),F(Q,l&1),e===null)return Os(t),e=t.memoizedState,e!==null&&(e=e.dehydrated,e!==null)?(t.mode&1?e.data==="$!"?t.lanes=8:t.lanes=1073741824:t.lanes=1,null):(o=r.children,e=r.fallback,s?(r=t.mode,s=t.child,o={mode:"hidden",children:o},!(r&1)&&s!==null?(s.childLanes=0,s.pendingProps=o):s=El(o,r,0,null),e=Rt(e,r,n,null),s.return=t,e.return=t,s.sibling=e,t.child=s,t.child.memoizedState=Qs(n),t.memoizedState=Ws,e):$o(t,o));if(l=e.memoizedState,l!==null&&(u=l.dehydrated,u!==null))return Ud(e,t,o,r,u,l,n);if(s){s=r.fallback,o=t.mode,l=e.child,u=l.sibling;var a={mode:"hidden",children:r.children};return!(o&1)&&t.child!==l?(r=t.child,r.childLanes=0,r.pendingProps=a,t.deletions=null):(r=wt(l,a),r.subtreeFlags=l.subtreeFlags&14680064),u!==null?s=wt(u,s):(s=Rt(s,o,n,null),s.flags|=2),s.return=t,r.return=t,r.sibling=s,t.child=r,r=s,s=t.child,o=e.child.memoizedState,o=o===null?Qs(n):{baseLanes:o.baseLanes|n,cachePool:null,transitions:o.transitions},s.memoizedState=o,s.childLanes=e.childLanes&~n,t.memoizedState=Ws,r}return s=e.child,e=s.sibling,r=wt(s,{mode:"visible",children:r.children}),!(t.mode&1)&&(r.lanes=n),r.return=t,r.sibling=null,e!==null&&(n=t.deletions,n===null?(t.deletions=[e],t.flags|=16):n.push(e)),t.child=r,t.memoizedState=null,r}function $o(e,t){return t=El({mode:"visible",children:t},e.mode,0,null),t.return=e,e.child=t}function jr(e,t,n,r){return r!==null&&_o(r),pn(t,e.child,null,n),e=$o(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function Ud(e,t,n,r,l,s,o){if(n)return t.flags&256?(t.flags&=-257,r=ts(Error(k(422))),jr(e,t,o,r)):t.memoizedState!==null?(t.child=e.child,t.flags|=128,null):(s=r.fallback,l=t.mode,r=El({mode:"visible",children:r.children},l,0,null),s=Rt(s,l,o,null),s.flags|=2,r.return=t,s.return=t,r.sibling=s,t.child=r,t.mode&1&&pn(t,e.child,null,o),t.child.memoizedState=Qs(o),t.memoizedState=Ws,s);if(!(t.mode&1))return jr(e,t,o,null);if(l.data==="$!"){if(r=l.nextSibling&&l.nextSibling.dataset,r)var u=r.dgst;return r=u,s=Error(k(419)),r=ts(s,r,void 0),jr(e,t,o,r)}if(u=(o&e.childLanes)!==0,me||u){if(r=ee,r!==null){switch(o&-o){case 4:l=2;break;case 16:l=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:l=32;break;case 536870912:l=268435456;break;default:l=0}l=l&(r.suspendedLanes|o)?0:l,l!==0&&l!==s.retryLane&&(s.retryLane=l,nt(e,l),Fe(r,e,l,-1))}return Qo(),r=ts(Error(k(421))),jr(e,t,o,r)}return l.data==="$?"?(t.flags|=128,t.child=e.child,t=ep.bind(null,e),l._reactRetry=t,null):(e=s.treeContext,xe=mt(l.nextSibling),ke=t,B=!0,Ie=null,e!==null&&(_e[Ne++]=Je,_e[Ne++]=qe,_e[Ne++]=It,Je=e.id,qe=e.overflow,It=t),t=$o(t,r.children),t.flags|=4096,t)}function Gi(e,t,n){e.lanes|=t;var r=e.alternate;r!==null&&(r.lanes|=t),Fs(e.return,t,n)}function ns(e,t,n,r,l){var s=e.memoizedState;s===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:r,tail:n,tailMode:l}:(s.isBackwards=t,s.rendering=null,s.renderingStartTime=0,s.last=r,s.tail=n,s.tailMode=l)}function lc(e,t,n){var r=t.pendingProps,l=r.revealOrder,s=r.tail;if(ue(e,t,r.children,n),r=Q.current,r&2)r=r&1|2,t.flags|=128;else{if(e!==null&&e.flags&128)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&Gi(e,n,t);else if(e.tag===19)Gi(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}r&=1}if(F(Q,r),!(t.mode&1))t.memoizedState=null;else switch(l){case"forwards":for(n=t.child,l=null;n!==null;)e=n.alternate,e!==null&&ol(e)===null&&(l=n),n=n.sibling;n=l,n===null?(l=t.child,t.child=null):(l=n.sibling,n.sibling=null),ns(t,!1,l,n,s);break;case"backwards":for(n=null,l=t.child,t.child=null;l!==null;){if(e=l.alternate,e!==null&&ol(e)===null){t.child=l;break}e=l.sibling,l.sibling=n,n=l,l=e}ns(t,!0,n,null,s);break;case"together":ns(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function Vr(e,t){!(t.mode&1)&&e!==null&&(e.alternate=null,t.alternate=null,t.flags|=2)}function rt(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),Ot|=t.lanes,!(n&t.childLanes))return null;if(e!==null&&t.child!==e.child)throw Error(k(153));if(t.child!==null){for(e=t.child,n=wt(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=wt(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function Hd(e,t,n){switch(t.tag){case 3:nc(t),dn();break;case 5:Pa(t);break;case 1:ye(t.type)&&el(t);break;case 4:To(t,t.stateNode.containerInfo);break;case 10:var r=t.type._context,l=t.memoizedProps.value;F(rl,r._currentValue),r._currentValue=l;break;case 13:if(r=t.memoizedState,r!==null)return r.dehydrated!==null?(F(Q,Q.current&1),t.flags|=128,null):n&t.child.childLanes?rc(e,t,n):(F(Q,Q.current&1),e=rt(e,t,n),e!==null?e.sibling:null);F(Q,Q.current&1);break;case 19:if(r=(n&t.childLanes)!==0,e.flags&128){if(r)return lc(e,t,n);t.flags|=128}if(l=t.memoizedState,l!==null&&(l.rendering=null,l.tail=null,l.lastEffect=null),F(Q,Q.current),r)break;return null;case 22:case 23:return t.lanes=0,ec(e,t,n)}return rt(e,t,n)}var sc,Ks,oc,ic;sc=function(e,t){for(var n=t.child;n!==null;){if(n.tag===5||n.tag===6)e.appendChild(n.stateNode);else if(n.tag!==4&&n.child!==null){n.child.return=n,n=n.child;continue}if(n===t)break;for(;n.sibling===null;){if(n.return===null||n.return===t)return;n=n.return}n.sibling.return=n.return,n=n.sibling}};Ks=function(){};oc=function(e,t,n,r){var l=e.memoizedProps;if(l!==r){e=t.stateNode,Pt(We.current);var s=null;switch(n){case"input":l=hs(e,l),r=hs(e,r),s=[];break;case"select":l=Y({},l,{value:void 0}),r=Y({},r,{value:void 0}),s=[];break;case"textarea":l=ys(e,l),r=ys(e,r),s=[];break;default:typeof l.onClick!="function"&&typeof r.onClick=="function"&&(e.onclick=qr)}ws(n,r);var o;n=null;for(f in l)if(!r.hasOwnProperty(f)&&l.hasOwnProperty(f)&&l[f]!=null)if(f==="style"){var u=l[f];for(o in u)u.hasOwnProperty(o)&&(n||(n={}),n[o]="")}else f!=="dangerouslySetInnerHTML"&&f!=="children"&&f!=="suppressContentEditableWarning"&&f!=="suppressHydrationWarning"&&f!=="autoFocus"&&(Hn.hasOwnProperty(f)?s||(s=[]):(s=s||[]).push(f,null));for(f in r){var a=r[f];if(u=l?.[f],r.hasOwnProperty(f)&&a!==u&&(a!=null||u!=null))if(f==="style")if(u){for(o in u)!u.hasOwnProperty(o)||a&&a.hasOwnProperty(o)||(n||(n={}),n[o]="");for(o in a)a.hasOwnProperty(o)&&u[o]!==a[o]&&(n||(n={}),n[o]=a[o])}else n||(s||(s=[]),s.push(f,n)),n=a;else f==="dangerouslySetInnerHTML"?(a=a?a.__html:void 0,u=u?u.__html:void 0,a!=null&&u!==a&&(s=s||[]).push(f,a)):f==="children"?typeof a!="string"&&typeof a!="number"||(s=s||[]).push(f,""+a):f!=="suppressContentEditableWarning"&&f!=="suppressHydrationWarning"&&(Hn.hasOwnProperty(f)?(a!=null&&f==="onScroll"&&$("scroll",e),s||u===a||(s=[])):(s=s||[]).push(f,a))}n&&(s=s||[]).push("style",n);var f=s;(t.updateQueue=f)&&(t.flags|=4)}};ic=function(e,t,n,r){n!==r&&(t.flags|=4)};function En(e,t){if(!B)switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var r=null;n!==null;)n.alternate!==null&&(r=n),n=n.sibling;r===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:r.sibling=null}}function se(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,r=0;if(t)for(var l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags&14680064,r|=l.flags&14680064,l.return=e,l=l.sibling;else for(l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags,r|=l.flags,l.return=e,l=l.sibling;return e.subtreeFlags|=r,e.childLanes=n,t}function Bd(e,t,n){var r=t.pendingProps;switch(jo(t),t.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return se(t),null;case 1:return ye(t.type)&&br(),se(t),null;case 3:return r=t.stateNode,hn(),V(ve),V(ie),Ro(),r.pendingContext&&(r.context=r.pendingContext,r.pendingContext=null),(e===null||e.child===null)&&(Sr(t)?t.flags|=4:e===null||e.memoizedState.isDehydrated&&!(t.flags&256)||(t.flags|=1024,Ie!==null&&(eo(Ie),Ie=null))),Ks(e,t),se(t),null;case 5:zo(t);var l=Pt(er.current);if(n=t.type,e!==null&&t.stateNode!=null)oc(e,t,n,r,l),e.ref!==t.ref&&(t.flags|=512,t.flags|=2097152);else{if(!r){if(t.stateNode===null)throw Error(k(166));return se(t),null}if(e=Pt(We.current),Sr(t)){r=t.stateNode,n=t.type;var s=t.memoizedProps;switch(r[He]=t,r[qn]=s,e=(t.mode&1)!==0,n){case"dialog":$("cancel",r),$("close",r);break;case"iframe":case"object":case"embed":$("load",r);break;case"video":case"audio":for(l=0;l<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[He]=t,e[qn]=r,sc(e,t,!1,!1),t.stateNode=e;e:{switch(o=xs(n,r),n){case"dialog":$("cancel",e),$("close",e),l=r;break;case"iframe":case"object":case"embed":$("load",e),l=r;break;case"video":case"audio":for(l=0;lvn&&(t.flags|=128,r=!0,En(s,!1),t.lanes=4194304)}else{if(!r)if(e=ol(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),En(s,!0),s.tail===null&&s.tailMode==="hidden"&&!o.alternate&&!B)return se(t),null}else 2*G()-s.renderingStartTime>vn&&n!==1073741824&&(t.flags|=128,r=!0,En(s,!1),t.lanes=4194304);s.isBackwards?(o.sibling=t.child,t.child=o):(n=s.last,n!==null?n.sibling=o:t.child=o,s.last=o)}return s.tail!==null?(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=G(),t.sibling=null,n=Q.current,F(Q,r?n&1|2:n&1),t):(se(t),null);case 22:case 23:return Wo(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?we&1073741824&&(se(t),t.subtreeFlags&6&&(t.flags|=8192)):se(t),null;case 24:return null;case 25:return null}throw Error(k(156,t.tag))}function Wd(e,t){switch(jo(t),t.tag){case 1:return ye(t.type)&&br(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return hn(),V(ve),V(ie),Ro(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return zo(t),null;case 13:if(V(Q),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(k(340));dn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return V(Q),null;case 4:return hn(),null;case 10:return Mo(t.type._context),null;case 22:case 23:return Wo(),null;case 24:return null;default:return null}}var _r=!1,oe=!1,Qd=typeof WeakSet=="function"?WeakSet:Set,E=null;function tn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){X(e,t,r)}else n.current=null}function Ys(e,t,n){try{n()}catch(r){X(e,t,r)}}var Zi=!1;function Kd(e,t){if(Ps=Gr,e=da(),So(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,s=r.focusNode;r=r.focusOffset;try{n.nodeType,s.nodeType}catch{n=null;break e}var o=0,u=-1,a=-1,f=0,h=0,v=e,m=null;t:for(;;){for(var x;v!==n||l!==0&&v.nodeType!==3||(u=o+l),v!==s||r!==0&&v.nodeType!==3||(a=o+r),v.nodeType===3&&(o+=v.nodeValue.length),(x=v.firstChild)!==null;)m=v,v=x;for(;;){if(v===e)break t;if(m===n&&++f===l&&(u=o),m===s&&++h===r&&(a=o),(x=v.nextSibling)!==null)break;v=m,m=v.parentNode}v=x}n=u===-1||a===-1?null:{start:u,end:a}}else n=null}n=n||{start:0,end:0}}else n=null;for(Ts={focusedElem:e,selectionRange:n},Gr=!1,E=t;E!==null;)if(t=E,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,E=e;else for(;E!==null;){t=E;try{var w=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(w!==null){var S=w.memoizedProps,T=w.memoizedState,d=t.stateNode,c=d.getSnapshotBeforeUpdate(t.elementType===t.type?S:ze(t.type,S),T);d.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var p=t.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(k(163))}}catch(y){X(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,E=e;break}E=t.return}return w=Zi,Zi=!1,w}function $n(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var s=l.destroy;l.destroy=void 0,s!==void 0&&Ys(t,n,s)}l=l.next}while(l!==r)}}function _l(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Xs(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function uc(e){var t=e.alternate;t!==null&&(e.alternate=null,uc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[He],delete t[qn],delete t[Ds],delete t[Md],delete t[Ld])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function ac(e){return e.tag===5||e.tag===3||e.tag===4}function Ji(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||ac(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Gs(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=qr));else if(r!==4&&(e=e.child,e!==null))for(Gs(e,t,n),e=e.sibling;e!==null;)Gs(e,t,n),e=e.sibling}function Zs(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Zs(e,t,n),e=e.sibling;e!==null;)Zs(e,t,n),e=e.sibling}var te=null,Re=!1;function st(e,t,n){for(n=n.child;n!==null;)cc(e,t,n),n=n.sibling}function cc(e,t,n){if(Be&&typeof Be.onCommitFiberUnmount=="function")try{Be.onCommitFiberUnmount(yl,n)}catch{}switch(n.tag){case 5:oe||tn(n,t);case 6:var r=te,l=Re;te=null,st(e,t,n),te=r,Re=l,te!==null&&(Re?(e=te,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):te.removeChild(n.stateNode));break;case 18:te!==null&&(Re?(e=te,n=n.stateNode,e.nodeType===8?Gl(e.parentNode,n):e.nodeType===1&&Gl(e,n),Yn(e)):Gl(te,n.stateNode));break;case 4:r=te,l=Re,te=n.stateNode.containerInfo,Re=!0,st(e,t,n),te=r,Re=l;break;case 0:case 11:case 14:case 15:if(!oe&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var s=l,o=s.destroy;s=s.tag,o!==void 0&&(s&2||s&4)&&Ys(n,t,o),l=l.next}while(l!==r)}st(e,t,n);break;case 1:if(!oe&&(tn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(u){X(n,t,u)}st(e,t,n);break;case 21:st(e,t,n);break;case 22:n.mode&1?(oe=(r=oe)||n.memoizedState!==null,st(e,t,n),oe=r):st(e,t,n);break;default:st(e,t,n)}}function qi(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Qd),t.forEach(function(r){var l=tp.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Te(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~s}if(r=l,r=G()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Xd(r/1960))-r,10e?16:e,ft===null)var r=!1;else{if(e=ft,ft=null,fl=0,A&6)throw Error(k(331));var l=A;for(A|=4,E=e.current;E!==null;){var s=E,o=s.child;if(E.flags&16){var u=s.deletions;if(u!==null){for(var a=0;aG()-Ho?zt(e,0):Uo|=n),ge(e,t)}function gc(e,t){t===0&&(e.mode&1?(t=vr,vr<<=1,!(vr&130023424)&&(vr=4194304)):t=1);var n=ce();e=nt(e,t),e!==null&&(or(e,t,n),ge(e,n))}function ep(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),gc(e,n)}function tp(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(k(314))}r!==null&&r.delete(t),gc(e,n)}var wc;wc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ve.current)me=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return me=!1,Hd(e,t,n);me=!!(e.flags&131072)}else me=!1,B&&t.flags&1048576&&Ca(t,nl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Vr(e,t),e=t.pendingProps;var l=fn(t,ie.current);un(t,n),l=Io(null,t,r,e,l,n);var s=Ao();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,ye(r)?(s=!0,el(t)):s=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Po(t),l.updater=jl,t.stateNode=l,l._reactInternals=t,Vs(t,r,e,n),t=Bs(null,t,r,!0,s,n)):(t.tag=0,B&&s&&Co(t),ue(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Vr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=rp(r),e=ze(r,e),l){case 0:t=Hs(null,t,r,e,n);break e;case 1:t=Yi(null,t,r,e,n);break e;case 11:t=Qi(null,t,r,e,n);break e;case 14:t=Ki(null,t,r,ze(r.type,e),n);break e}throw Error(k(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Hs(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Yi(e,t,r,l,n);case 3:e:{if(nc(t),e===null)throw Error(k(387));r=t.pendingProps,s=t.memoizedState,l=s.element,La(e,t),sl(t,r,null,n);var o=t.memoizedState;if(r=o.element,s.isDehydrated)if(s={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=s,t.memoizedState=s,t.flags&256){l=mn(Error(k(423)),t),t=Xi(e,t,r,n,l);break e}else if(r!==l){l=mn(Error(k(424)),t),t=Xi(e,t,r,n,l);break e}else for(xe=mt(t.stateNode.containerInfo.firstChild),ke=t,B=!0,Ie=null,n=Ea(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(dn(),r===l){t=rt(e,t,n);break e}ue(e,t,r,n)}t=t.child}return t;case 5:return Pa(t),e===null&&Os(t),r=t.type,l=t.pendingProps,s=e!==null?e.memoizedProps:null,o=l.children,zs(r,l)?o=null:s!==null&&zs(r,s)&&(t.flags|=32),tc(e,t),ue(e,t,o,n),t.child;case 6:return e===null&&Os(t),null;case 13:return rc(e,t,n);case 4:return To(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=pn(t,null,r,n):ue(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Qi(e,t,r,l,n);case 7:return ue(e,t,t.pendingProps,n),t.child;case 8:return ue(e,t,t.pendingProps.children,n),t.child;case 12:return ue(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,s=t.memoizedProps,o=l.value,F(rl,r._currentValue),r._currentValue=o,s!==null)if($e(s.value,o)){if(s.children===l.children&&!ve.current){t=rt(e,t,n);break e}}else for(s=t.child,s!==null&&(s.return=t);s!==null;){var u=s.dependencies;if(u!==null){o=s.child;for(var a=u.firstContext;a!==null;){if(a.context===r){if(s.tag===1){a=be(-1,n&-n),a.tag=2;var f=s.updateQueue;if(f!==null){f=f.shared;var h=f.pending;h===null?a.next=a:(a.next=h.next,h.next=a),f.pending=a}}s.lanes|=n,a=s.alternate,a!==null&&(a.lanes|=n),Fs(s.return,n,t),u.lanes|=n;break}a=a.next}}else if(s.tag===10)o=s.type===t.type?null:s.child;else if(s.tag===18){if(o=s.return,o===null)throw Error(k(341));o.lanes|=n,u=o.alternate,u!==null&&(u.lanes|=n),Fs(o,n,t),o=s.sibling}else o=s.child;if(o!==null)o.return=s;else for(o=s;o!==null;){if(o===t){o=null;break}if(s=o.sibling,s!==null){s.return=o.return,o=s;break}o=o.return}s=o}ue(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,un(t,n),l=Le(l),r=r(l),t.flags|=1,ue(e,t,r,n),t.child;case 14:return r=t.type,l=ze(r,t.pendingProps),l=ze(r.type,l),Ki(e,t,r,l,n);case 15:return ba(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Vr(e,t),t.tag=1,ye(r)?(e=!0,el(t)):e=!1,un(t,n),Za(t,r,l),Vs(t,r,l,n),Bs(null,t,r,!0,e,n);case 19:return lc(e,t,n);case 22:return ec(e,t,n)}throw Error(k(156,t.tag))};function xc(e,t){return Yu(e,t)}function np(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ee(e,t,n,r){return new np(e,t,n,r)}function Ko(e){return e=e.prototype,!(!e||!e.isReactComponent)}function rp(e){if(typeof e=="function")return Ko(e)?1:0;if(e!=null){if(e=e.$$typeof,e===co)return 11;if(e===fo)return 14}return 2}function wt(e,t){var n=e.alternate;return n===null?(n=Ee(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Br(e,t,n,r,l,s){var o=2;if(r=e,typeof e=="function")Ko(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Kt:return Rt(n.children,l,s,t);case ao:o=8,l|=8;break;case cs:return e=Ee(12,n,t,l|2),e.elementType=cs,e.lanes=s,e;case fs:return e=Ee(13,n,t,l),e.elementType=fs,e.lanes=s,e;case ds:return e=Ee(19,n,t,l),e.elementType=ds,e.lanes=s,e;case Pu:return El(n,l,s,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Mu:o=10;break e;case Lu:o=9;break e;case co:o=11;break e;case fo:o=14;break e;case ot:o=16,r=null;break e}throw Error(k(130,e==null?e:typeof e,""))}return t=Ee(o,n,t,l),t.elementType=e,t.type=r,t.lanes=s,t}function Rt(e,t,n,r){return e=Ee(7,e,r,t),e.lanes=n,e}function El(e,t,n,r){return e=Ee(22,e,r,t),e.elementType=Pu,e.lanes=n,e.stateNode={isHidden:!1},e}function rs(e,t,n){return e=Ee(6,e,null,t),e.lanes=n,e}function ls(e,t,n){return t=Ee(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function lp(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Fl(0),this.expirationTimes=Fl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Fl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Yo(e,t,n,r,l,s,o,u,a){return e=new lp(e,t,n,u,a),t===1?(t=1,s===!0&&(t|=8)):t=0,s=Ee(3,null,null,t),e.current=s,s.stateNode=e,s.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Po(s),e}function sp(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(jc)}catch(e){console.error(e)}}jc(),ju.exports=Ce;var cp=ju.exports,ou=cp;us.createRoot=ou.createRoot,us.hydrateRoot=ou.hydrateRoot;function fp({strokeWidth:e=60,...t}){return i.jsx("svg",{viewBox:"0 0 1188 1773",fill:"none",xmlns:"http://www.w3.org/2000/svg",role:"img","aria-hidden":"true",...t,children:i.jsx("path",{d:"M25 561L245 694M25 561V818M245 694V951M25 961V1218M25 1357V1614M245 1489V1747M245 1093V1351M942 823V1080M1161 955V1213M1162 555V812M942 422V679M669 585V843L787 913M942 25V282M1162 158V415M25 818L245 951M244 1094L464 962M25 961L143 890M244 1352L464 1219M942 823L1162 956M942 679L1162 812M721 811L942 679M669 842L724 809M669 586L724 553M1041 883L1162 812M245 1747L1161 1213M244 1490L942 1080M25 1357L142 1289M518 1071L942 823M721 555L942 422M942 422L1162 556M942 282L1162 415M942 25L1162 158M942 1080L1161 1213M25 1218L245 1351M25 961L245 1094M464 962L519 929M464 1219L519 1186V928L403 859M25 1357L245 1490M25 1614L245 1747M25 561L942 25M244 694L941 282M1043 484L1162 415M245 951L668 704",stroke:"currentColor",strokeWidth:e,strokeLinecap:"round"})})}function dp(e){return i.jsxs("svg",{viewBox:"269 80 364 110",fill:"none",xmlns:"http://www.w3.org/2000/svg",role:"img","aria-label":"Patter",...e,children:[i.jsx("path",{d:"M271.422 182.689V85.9524H317.517C324.705 85.9524 330.86 87.2064 335.982 89.7143C341.193 92.2223 345.192 95.7156 347.977 100.194C350.852 104.673 352.29 109.913 352.29 115.914C352.29 121.915 350.852 127.2 347.977 131.768C345.102 136.336 341.058 139.919 335.847 142.516C330.725 145.024 324.615 146.278 317.517 146.278H287.866V130.424H316.439C321.201 130.424 324.885 129.125 327.491 126.528C330.186 123.841 331.534 120.348 331.534 116.048C331.534 111.749 330.186 108.3 327.491 105.703C324.885 103.105 321.201 101.806 316.439 101.806H292.178V182.689H271.422Z",fill:"currentColor"}),i.jsx("path",{d:"M395.375 182.689C394.836 180.718 394.432 178.613 394.162 176.374C393.982 174.135 393.893 171.537 393.893 168.581H393.353V136.202C393.353 133.425 392.41 131.275 390.523 129.752C388.726 128.14 386.03 127.334 382.436 127.334C379.022 127.334 376.281 127.916 374.215 129.081C372.238 130.245 370.935 131.947 370.306 134.186H351.033C351.931 128.006 355.121 122.9 360.602 118.87C366.083 114.839 373.586 112.824 383.11 112.824C392.994 112.824 400.542 115.018 405.753 119.407C410.965 123.796 413.57 130.111 413.57 138.351V168.581C413.57 170.821 413.705 173.105 413.975 175.434C414.334 177.673 414.873 180.091 415.592 182.689H395.375ZM371.384 184.032C364.556 184.032 359.12 182.33 355.076 178.927C351.033 175.434 349.011 170.821 349.011 165.088C349.011 158.729 351.392 153.623 356.154 149.772C361.006 145.83 367.745 143.278 376.371 142.113L396.453 139.292V150.981L379.741 153.533C376.147 154.071 373.496 155.056 371.789 156.489C370.082 157.922 369.228 159.893 369.228 162.401C369.228 164.64 370.037 166.342 371.654 167.507C373.271 168.671 375.428 169.253 378.123 169.253C382.347 169.253 385.941 168.134 388.906 165.894C391.871 163.565 393.353 160.878 393.353 157.833L395.24 168.581C393.264 173.687 390.254 177.538 386.21 180.136C382.167 182.734 377.225 184.032 371.384 184.032Z",fill:"currentColor"}),i.jsx("path",{d:"M450.248 184.167C441.443 184.167 434.883 182.062 430.57 177.852C426.347 173.553 424.236 167.059 424.236 158.37V98.8506L444.453 91.3266V159.042C444.453 162.087 445.306 164.372 447.014 165.894C448.721 167.417 451.371 168.178 454.966 168.178C456.313 168.178 457.571 168.044 458.739 167.775C459.907 167.507 461.075 167.193 462.244 166.835V182.151C461.075 182.778 459.413 183.271 457.257 183.629C455.19 183.988 452.854 184.167 450.248 184.167ZM411.432 129.484V114.167H462.244V129.484H411.432Z",fill:"currentColor"}),i.jsx("path",{d:"M500.501 184.167C491.695 184.167 485.136 182.062 480.823 177.852C476.6 173.553 474.489 167.059 474.489 158.37V98.8506L494.705 91.3266V159.042C494.705 162.087 495.559 164.372 497.266 165.894C498.973 167.417 501.624 168.178 505.218 168.178C506.566 168.178 507.824 168.044 508.992 167.775C510.16 167.507 511.328 167.193 512.496 166.835V182.151C511.328 182.778 509.666 183.271 507.509 183.629C505.443 183.988 503.107 184.167 500.501 184.167ZM461.684 129.484V114.167H512.496V129.484H461.684Z",fill:"currentColor"}),i.jsx("path",{d:"M547.852 184.032C540.214 184.032 533.565 182.554 527.904 179.599C522.244 176.553 517.841 172.343 514.696 166.969C511.641 161.595 510.113 155.414 510.113 148.428C510.113 141.352 511.641 135.171 514.696 129.887C517.841 124.513 522.199 120.348 527.769 117.392C533.34 114.346 539.81 112.824 547.178 112.824C554.276 112.824 560.431 114.257 565.642 117.123C570.854 119.989 574.897 123.975 577.773 129.081C580.648 134.186 582.086 140.187 582.086 147.084C582.086 148.518 582.041 149.861 581.951 151.115C581.861 152.279 581.726 153.399 581.546 154.474H521.974V141.173H565.238L561.734 143.591C561.734 138.038 560.386 133.962 557.69 131.365C555.085 128.678 551.491 127.334 546.908 127.334C541.607 127.334 537.474 129.125 534.508 132.708C531.633 136.291 530.196 141.665 530.196 148.831C530.196 155.818 531.633 161.013 534.508 164.416C537.474 167.82 541.876 169.522 547.717 169.522C550.952 169.522 553.737 168.984 556.073 167.91C558.409 166.835 560.161 165.088 561.33 162.67H580.333C578.087 169.298 574.223 174.538 568.742 178.389C563.351 182.151 556.388 184.032 547.852 184.032Z",fill:"currentColor"}),i.jsx("path",{d:"M586.158 182.689V114.167H605.971V130.29H606.375V182.689H586.158ZM606.375 146.95L604.623 130.693C606.24 124.871 608.891 120.437 612.575 117.392C616.259 114.346 620.842 112.824 626.323 112.824C628.03 112.824 629.288 113.003 630.096 113.361V132.171C629.647 131.992 629.018 131.902 628.21 131.902C627.401 131.813 626.412 131.768 625.244 131.768C618.775 131.768 614.013 132.932 610.958 135.261C607.903 137.5 606.375 141.397 606.375 146.95Z",fill:"currentColor"})]})}function pp(){return i.jsxs("span",{className:"patter-logo",style:{display:"inline-flex",alignItems:"center",gap:8},"aria-label":"Patter",children:[i.jsx(fp,{height:26}),i.jsx(dp,{height:24})]})}function hl(e){const t=Math.floor(e/60),n=Math.floor(e%60);return`${String(t).padStart(2,"0")}:${String(n).padStart(2,"0")}`}function ml(e,t=!0){if(!e)return"";if(t)return e.startsWith("***")?"•••"+e.slice(3):e;if(e.startsWith("***"))return"•••"+e.slice(3);if(e.startsWith("sha256:"))return"••••••••";const n=e.replace(/\D/g,"");return n.length>=4?"•••"+n.slice(-4):"••••••••"}function De(e){if(e==null||!Number.isFinite(e))return"$0.00";const t=Math.abs(e);return t===0?"$0.00":t>=.01?`$${e.toFixed(2)}`:t>=.001?`$${e.toFixed(3)}`:t>=1e-4?`$${e.toFixed(4)}`:`$${e.toFixed(5)}`}function hp(e){return i.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("circle",{cx:"11",cy:"11",r:"7"}),i.jsx("path",{d:"m21 21-4.3-4.3"})]})}function _c(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.4",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M7 13l5 5 5-5"}),i.jsx("path",{d:"M12 4v14"})]})}function mp(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.4",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M17 11l-5-5-5 5"}),i.jsx("path",{d:"M12 20V6"})]})}function vp(e){return i.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"}),i.jsx("circle",{cx:"12",cy:"12",r:"3"})]})}function yp(e){return i.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M17.94 17.94A10.94 10.94 0 0 1 12 19c-6.5 0-10-7-10-7a18.5 18.5 0 0 1 5.06-5.94"}),i.jsx("path",{d:"M9.9 4.24A10.6 10.6 0 0 1 12 4c6.5 0 10 7 10 7a18.8 18.8 0 0 1-2.16 3.19"}),i.jsx("path",{d:"M14.12 14.12a3 3 0 1 1-4.24-4.24"}),i.jsx("path",{d:"M1 1l22 22"})]})}function gp(e){return i.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("circle",{cx:"12",cy:"12",r:"4"}),i.jsx("path",{d:"M12 2v2"}),i.jsx("path",{d:"M12 20v2"}),i.jsx("path",{d:"M4.93 4.93l1.41 1.41"}),i.jsx("path",{d:"M17.66 17.66l1.41 1.41"}),i.jsx("path",{d:"M2 12h2"}),i.jsx("path",{d:"M20 12h2"}),i.jsx("path",{d:"M4.93 19.07l1.41-1.41"}),i.jsx("path",{d:"M17.66 6.34l1.41-1.41"})]})}function wp(e){return i.jsx("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round",...e,children:i.jsx("path",{d:"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"})})}function iu(e){return i.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M3 6h18"}),i.jsx("path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"}),i.jsx("path",{d:"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"}),i.jsx("path",{d:"M10 11v6"}),i.jsx("path",{d:"M14 11v6"})]})}function xp(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.2",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M18 6L6 18"}),i.jsx("path",{d:"M6 6l12 12"})]})}function Nc(e){return i.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"3",strokeLinecap:"round",strokeLinejoin:"round",...e,children:i.jsx("path",{d:"M20 6 9 17l-5-5"})})}function kp(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("rect",{x:"9",y:"2",width:"6",height:"12",rx:"3"}),i.jsx("path",{d:"M19 10a7 7 0 0 1-14 0"}),i.jsx("path",{d:"M12 19v3"})]})}function Sp(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("polyline",{points:"15 17 20 12 15 7"}),i.jsx("path",{d:"M4 18v-2a4 4 0 0 1 4-4h12"})]})}function Cp(e){return i.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"currentColor",...e,children:i.jsx("circle",{cx:"12",cy:"12",r:"6"})})}function jp(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67"}),i.jsx("path",{d:"M22 2 2 22"})]})}function _p({liveCount:e,todayCount:t,phoneNumber:n,sdkVersion:r,revealed:l,dark:s,onToggleRevealed:o,onToggleDark:u}){const a=ml(n,l);return i.jsxs("header",{className:"top",children:[i.jsxs("div",{className:"brand",children:[i.jsx(pp,{}),i.jsxs("span",{className:"tag",children:["dashboard · v",r]})]}),i.jsxs("div",{className:"top-r",children:[i.jsxs("span",{className:"live-chip",children:[i.jsx("span",{className:"pulse"+(e>0?" active":"")}),e," live · ",t," today"]}),n&&n!=="—"&&i.jsx("span",{className:"num-chip",children:a}),i.jsx("button",{type:"button",className:"icon-btn toggle"+(l?" on":""),onClick:o,"aria-label":l?"Hide phone numbers":"Reveal phone numbers","aria-pressed":l,title:l?"Hide numbers":"Reveal numbers",children:l?i.jsx(vp,{}):i.jsx(yp,{})}),i.jsx("button",{type:"button",className:"icon-btn toggle"+(s?" on":""),onClick:u,"aria-label":s?"Switch to light theme":"Switch to dark theme","aria-pressed":s,title:s?"Light mode":"Dark mode",children:s?i.jsx(gp,{}):i.jsx(wp,{})})]})]})}const Np=["1h","24h","7d","All"];function Ep(){const e=document.createElement("a");e.href="/api/dashboard/export/calls?format=csv",e.download="patter_calls.csv",e.rel="noopener",document.body.appendChild(e),e.click(),document.body.removeChild(e)}function Mp({range:e,setRange:t}){return i.jsxs("div",{className:"ph",children:[i.jsxs("div",{children:[i.jsx("h1",{children:"Calls"}),i.jsxs("p",{className:"sub",children:["Real-time view of every call routed through this Patter instance."," ",i.jsx("span",{className:"kbd",children:"⇧K"})," to focus search."]})]}),i.jsxs("div",{className:"filters",children:[i.jsx("div",{className:"seg",children:Np.map(n=>i.jsx("button",{type:"button",className:e===n?"on":"",onClick:()=>t(n),children:n},n))}),i.jsxs("button",{className:"btn",type:"button",onClick:Ep,children:[i.jsx(_c,{})," Export CSV"]})]})]})}const Ec=60*60*1e3,Lp=24*Ec;function Mr(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}function Pp(e){return new Date(e).toLocaleDateString([],{weekday:"short",month:"short",day:"numeric"})}function uu(e){return new Date(e).toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}function Mc(e){const t=e.toMs-e.fromMs;return t>=Lp-Tp?Pp(e.fromMs):t>=Ec?`${Mr(e.fromMs)} → ${Mr(e.toMs)}`:t>=60*1e3?`${Mr(e.fromMs)} → ${Mr(e.toMs)}`:`${uu(e.fromMs)} → ${uu(e.toMs)}`}const Tp=5e3;function Lc(e){return e.cost.total??(e.cost.telco??0)+(e.cost.llm??0)+(e.cost.sttTts??0)}function zp(e){return e.calls.length===0?void 0:[...e.calls].sort((n,r)=>(r.startedAtMs??0)-(n.startedAtMs??0))[0]?.id}function Rp(e,t){const n=e.calls,r=n.length;if(t==="spend"){const l=n.reduce((s,o)=>s+Lc(o),0);return{label:"TOTAL COST",value:De(l)}}if(t==="latency"){const l=n.filter(o=>typeof o.latencyP95=="number");return{label:"AVG LATENCY",value:`${l.length>0?Math.round(l.reduce((o,u)=>o+(u.latencyP95??0),0)/l.length):0} ms`}}return{label:r===1?"CALL":"CALLS",value:`${r}`}}function Dp({bucket:e,kind:t}){const n=Mc(e),r=e.calls.length;if(r===0)return i.jsxs("div",{className:"spark-tooltip",children:[i.jsx("div",{className:"spark-tooltip-range",children:n}),i.jsx("div",{className:"spark-tooltip-empty",children:"no calls"})]});const l=Rp(e,t),s=e.calls.slice(0,4);return i.jsxs("div",{className:"spark-tooltip",children:[i.jsx("div",{className:"spark-tooltip-range",children:n}),i.jsxs("div",{className:"spark-tooltip-headline",children:[i.jsx("span",{className:"spark-tooltip-headline-l",children:l.label}),i.jsx("span",{className:"spark-tooltip-headline-v",children:l.value})]}),i.jsx("ul",{className:"spark-tooltip-list",children:s.map(o=>{const u=o.direction==="inbound"?o.from:o.to;return i.jsxs("li",{children:[i.jsx("span",{className:"num",children:u}),i.jsx("span",{className:"status",children:o.status}),i.jsx("span",{className:"cost",children:De(Lc(o))})]},o.id)})}),r>s.length&&i.jsxs("div",{className:"spark-tooltip-more",children:["+",r-s.length," more"]})]})}function Ip({bucket:e,height:t,interactive:n,kind:r,onSelect:l}){const[s,o]=M.useState(!1),u=!!e&&e.calls.length>0;return!n||!e?i.jsx("span",{className:"spark-bar-static",style:{height:t+"%"}}):i.jsxs("div",{className:"spark-bar-wrap",onMouseEnter:()=>o(!0),onMouseLeave:()=>o(!1),children:[i.jsx("button",{type:"button",className:"spark-bar"+(u?"":" empty"),style:{height:t+"%"},disabled:!u,onClick:()=>{if(!u)return;const a=zp(e);a&&l&&l(a)},onFocus:()=>o(!0),onBlur:()=>o(!1),"aria-label":`${e.calls.length} calls in ${Mc(e)}`}),s&&i.jsx(Dp,{bucket:e,kind:r})]})}function Lr({label:e,value:t,unit:n,delta:r,deltaTone:l,spark:s,buckets:o,onSelectCall:u,kind:a="count",peach:f,footer:h,badge:v}){const m=!!o&&!!u;return i.jsxs("div",{className:"metric"+(f?" peach":""),children:[i.jsxs("div",{className:"lbl",children:[i.jsx("span",{children:e}),v&&i.jsx("span",{className:"badge-now",children:"LIVE"})]}),i.jsxs("div",{className:"val",children:[t,n&&i.jsxs("span",{className:"unit",children:[" ",n]})]}),r&&i.jsx("div",{className:"delta "+(l||""),children:r}),h&&i.jsx("div",{className:"delta",children:h}),i.jsx("div",{className:"spark",children:s.map((x,w)=>i.jsx(Ip,{bucket:o?.[w],height:x,interactive:m,kind:a,onSelect:u},w))})]})}function Ap({call:e,isSelected:t,onSelect:n,isNew:r,isChecked:l,onToggleCheck:s,revealed:o}){const u=e.status==="live"&&e.durationStart?hl((Date.now()-e.durationStart)/1e3):hl(e.duration||0),a=e.latencyP95?Math.min(100,e.latencyP95/1e3*100):0,f=(e.latencyP95??0)>600,h=e.cost.total??(e.cost.telco??0)+(e.cost.llm??0)+(e.cost.sttTts??0),v=e.status.replace("-","");return i.jsxs("tr",{className:(t?"selected ":"")+(r?"new-row ":"")+(l?"checked":""),onClick:n,children:[i.jsx("td",{className:"check-cell",onClick:m=>{m.stopPropagation(),s&&s(m)},"aria-disabled":s===null,children:i.jsx("button",{type:"button",className:"row-check"+(l?" on":"")+(s===null?" disabled":""),"aria-label":s===null?"Live calls cannot be deleted":l?"Deselect call":"Select call","aria-pressed":l,disabled:s===null,onClick:m=>{m.stopPropagation(),s&&s(m)},tabIndex:s===null?-1:0,children:l?i.jsx(Nc,{}):null})}),i.jsx("td",{children:i.jsx("span",{className:"pill "+v,children:e.status})}),i.jsxs("td",{children:[i.jsx("span",{className:"dir in",style:{marginRight:8,color:e.direction==="inbound"?"#3b6f3b":"#4a4a4a"},children:e.direction==="inbound"?i.jsx(_c,{}):i.jsx(mp,{})}),i.jsxs("span",{className:"num-cell pii",children:[ml(e.from,o)," → ",ml(e.to,o)]})]}),i.jsx("td",{children:i.jsxs("span",{className:"car-tw",children:[i.jsx("span",{className:"car-dot "+(e.carrier==="twilio"?"tw":"tx")}),e.carrier==="twilio"?"Twilio":"Telnyx"]})}),i.jsx("td",{className:"num-cell",children:e.status==="no-answer"?"—":u}),i.jsx("td",{children:e.latencyP95?i.jsxs(i.Fragment,{children:[i.jsx("span",{className:"lat-bar"+(f?" warn":""),children:i.jsx("i",{style:{width:a+"%"}})}),i.jsxs("span",{className:"num-cell",children:[e.latencyP95," ms"]})]}):"—"}),i.jsx("td",{className:"num-cell",children:De(h)})]})}function Op({calls:e,selectedId:t,onSelect:n,newId:r,search:l,setSearch:s,onDeleteCalls:o,revealed:u}){const a=M.useMemo(()=>{if(!l.trim())return e;const g=l.toLowerCase();return e.filter(j=>j.from.toLowerCase().includes(g)||j.to.toLowerCase().includes(g)||j.status.includes(g)||j.carrier.includes(g)||j.id.includes(g))},[e,l]),[f,h]=M.useState(new Set),[v,m]=M.useState(!1),[x,w]=M.useState(!1),S=M.useMemo(()=>a.filter(g=>g.status!=="live").map(g=>g.id),[a]),T=M.useMemo(()=>S.filter(g=>f.has(g)),[S,f]),d=S.length>0&&T.length===S.length,c=T.length>0,p=g=>{h(j=>{const D=new Set(j);return D.has(g)?D.delete(g):D.add(g),D})},y=()=>{h(g=>{const j=new Set(g);if(d)for(const D of S)j.delete(D);else for(const D of S)j.add(D);return j})},_=()=>{h(new Set),m(!1)},C=async()=>{if(!(!o||T.length===0||x)){w(!0);try{await o(T),_()}finally{w(!1)}}};return i.jsxs("div",{className:"panel",children:[i.jsxs("div",{className:"panel-h",children:[i.jsxs("h3",{children:["Recent calls"," ",i.jsxs("span",{style:{fontFamily:"var(--font-mono)",fontSize:11,color:"#aaa",fontWeight:500,marginLeft:4},children:["(",a.length,")"]})]}),i.jsxs("div",{className:"search",children:[i.jsx(hp,{}),i.jsx("input",{placeholder:"Search number, status, carrier…",value:l,onChange:g=>s(g.target.value)})]}),i.jsxs("span",{className:"sse",children:[i.jsx("span",{className:"dot"}),"streaming · SSE"]})]}),c?i.jsxs("div",{className:"bulk-bar"+(v?" confirming":""),role:"region","aria-label":"Bulk actions",children:[i.jsxs("span",{className:"bulk-count",children:[i.jsx("span",{className:"bulk-num",children:T.length}),i.jsx("span",{className:"bulk-lbl",children:T.length===1?"call selected":"calls selected"})]}),i.jsx("div",{className:"bulk-spacer"}),v?i.jsxs(i.Fragment,{children:[i.jsx("span",{className:"bulk-warn",children:"Removes from view + metrics. Logs kept on disk."}),i.jsx("button",{type:"button",className:"bulk-btn ghost",onClick:()=>m(!1),disabled:x,children:"Cancel"}),i.jsxs("button",{type:"button",className:"bulk-btn destructive",onClick:()=>void C(),disabled:x,autoFocus:!0,children:[i.jsx(iu,{}),i.jsx("span",{children:x?"Deleting…":`Delete ${T.length}`})]})]}):i.jsxs(i.Fragment,{children:[i.jsxs("button",{type:"button",className:"bulk-btn ghost",onClick:_,"aria-label":"Clear selection",children:[i.jsx(xp,{}),i.jsx("span",{children:"Clear"})]}),i.jsxs("button",{type:"button",className:"bulk-btn destructive",onClick:()=>m(!0),children:[i.jsx(iu,{}),i.jsx("span",{children:"Delete"})]})]})]}):null,i.jsx("div",{style:{minHeight:540,maxHeight:540,overflow:"auto"},children:i.jsxs("table",{className:"call-table",children:[i.jsx("thead",{children:i.jsxs("tr",{children:[i.jsx("th",{className:"check-cell",children:i.jsx("button",{type:"button",className:"row-check head"+(d?" on":c?" indet":"")+(S.length===0?" disabled":""),onClick:y,disabled:S.length===0,"aria-label":d?"Deselect all":"Select all calls in view","aria-pressed":d,children:d?i.jsx(Nc,{}):c?i.jsx("span",{className:"indet-mark"}):null})}),i.jsx("th",{children:"Status"}),i.jsx("th",{children:"From → To"}),i.jsx("th",{children:"Carrier"}),i.jsx("th",{children:"Duration"}),i.jsx("th",{children:"p95 latency"}),i.jsx("th",{children:"Cost"})]})}),i.jsx("tbody",{children:a.length===0?i.jsx("tr",{children:i.jsxs("td",{colSpan:7,className:"empty",children:['No calls match "',l,'"']})}):a.map(g=>i.jsx(Ap,{call:g,isSelected:g.id===t,onSelect:()=>n(g.id),isNew:g.id===r,isChecked:f.has(g.id),onToggleCheck:g.status==="live"?null:()=>p(g.id),revealed:u},g.id))})]})})]})}function Fp({start:e}){const[,t]=M.useState(0);return M.useEffect(()=>{const n=setInterval(()=>t(r=>r+1),1e3);return()=>clearInterval(n)},[]),i.jsx(i.Fragment,{children:hl((Date.now()-e)/1e3)})}function $p({call:e,transcript:t,onEnd:n,recording:r,setRecording:l,muted:s,setMuted:o,revealed:u}){const a=M.useRef(null);if(M.useEffect(()=>{a.current&&(a.current.scrollTop=a.current.scrollHeight)},[t]),!e)return i.jsxs("div",{className:"rr-card",children:[i.jsx("h3",{children:"No live call selected"}),i.jsx("div",{className:"meta",children:"Select a call from the table — or wait for the next ring."})]});const f=e.status==="live";return i.jsxs("div",{className:"rr-card",children:[i.jsxs("h3",{children:["Live call",i.jsx("span",{className:"pill "+(f?"live":"done"),children:e.status})]}),i.jsxs("div",{className:"meta",children:[i.jsx("strong",{className:"pii",children:ml(e.direction==="inbound"?e.from:e.to,u)}),i.jsx("span",{className:"sep",children:"·"}),e.agent]}),i.jsxs("div",{className:"duration-block",children:[i.jsx("span",{className:"l",children:"duration"}),i.jsxs("span",{className:"agent",children:[e.direction==="inbound"?"inbound":"outbound"," ·"," ",e.carrier==="twilio"?"Twilio":"Telnyx"]}),i.jsx("span",{className:"v",children:f&&e.durationStart?i.jsx(Fp,{start:e.durationStart}):hl(e.duration||0)})]}),i.jsx("div",{className:"transcript",ref:a,children:t.map((h,v)=>h.who==="tool"?i.jsxs("div",{className:"turn tool",children:[i.jsx("div",{className:"av",children:"⚙"}),i.jsxs("div",{className:"body",children:[i.jsxs("div",{className:"who",children:["tool · ",h.txt]}),h.args&&i.jsx("div",{className:"tool-call",children:Object.entries(h.args).map(([m,x])=>i.jsxs("span",{children:[i.jsxs("span",{className:"k",children:[m,":"]}),' "',String(x),'"'," "]},m))})]})]},v):i.jsxs("div",{className:"turn "+h.who,children:[i.jsx("div",{className:"av",children:h.who==="user"?"U":"P"}),i.jsxs("div",{className:"body",children:[i.jsxs("div",{className:"who",children:[h.who==="user"?"caller":"agent",h.typing&&" · typing"]}),i.jsx("div",{className:"txt",children:h.typing?i.jsxs("span",{className:"typing",children:[i.jsx("span",{}),i.jsx("span",{}),i.jsx("span",{})]}):h.txt}),h.lat&&!h.typing&&i.jsxs("div",{className:"lat",children:[h.lat.stt&&`stt ${h.lat.stt} ms`,h.lat.total&&`total ${h.lat.total} ms · llm ${h.lat.llm} · tts ${h.lat.tts}`]})]})]},v))}),f&&i.jsxs("div",{className:"controls",children:[i.jsxs("button",{type:"button",className:"ctrl"+(s?" active":""),onClick:()=>o(!s),children:[i.jsx(kp,{})," ",s?"unmute":"mute"]}),i.jsxs("button",{type:"button",className:"ctrl",children:[i.jsx(Sp,{})," transfer"]}),i.jsxs("button",{type:"button",className:"ctrl"+(r?" active":""),onClick:()=>l(!r),children:[i.jsx(Cp,{})," ",r?"stop rec":"record"]}),i.jsxs("button",{type:"button",className:"ctrl danger",onClick:n,children:[i.jsx(jp,{})," end"]})]})]})}const Vp=e=>!!e&&typeof e.latencyP95=="number",Up=e=>!!e&&(typeof e.cost.telco=="number"||typeof e.cost.llm=="number"||typeof e.cost.sttTts=="number"||typeof e.cost.total=="number");function Hp({call:e}){const[t,n]=M.useState("latency"),r=Vp(e),l=Up(e);if(!e||!r&&!l)return null;const s=t==="latency"&&!r?"cost":t==="cost"&&!l?"latency":t;return i.jsxs("div",{className:"rr-card metrics-panel",children:[i.jsx("div",{className:"metrics-panel-h",children:i.jsxs("div",{className:"seg",role:"tablist",children:[i.jsx("button",{type:"button",role:"tab","aria-selected":s==="latency",disabled:!r,className:s==="latency"?"on":"",onClick:()=>n("latency"),children:"Latency"}),i.jsx("button",{type:"button",role:"tab","aria-selected":s==="cost",disabled:!l,className:s==="cost"?"on":"",onClick:()=>n("cost"),children:"Cost"})]})}),i.jsxs("div",{className:"metrics-panel-body",children:[s==="latency"&&r&&i.jsx(Bp,{call:e}),s==="cost"&&l&&i.jsx(Wp,{call:e})]})]})}function Bp({call:e}){const t=e.latencyP50??0,n=e.latencyP95??0;if(e.mode==="realtime"){const h=(e.turnCount??0)>=2;return i.jsxs(i.Fragment,{children:[i.jsxs("div",{className:"lat-grid",children:[i.jsxs("div",{className:"latbox",children:[i.jsx("div",{className:"l",children:"end-to-end p50"}),i.jsxs("div",{className:"v",children:[h&&t||"—",h&&i.jsx("span",{className:"u",children:"ms"})]})]}),i.jsxs("div",{className:"latbox"+(h&&n>600?" warn":""),children:[i.jsx("div",{className:"l",children:"end-to-end p95"}),i.jsxs("div",{className:"v",children:[h&&n||"—",h&&i.jsx("span",{className:"u",children:"ms"})]})]})]}),i.jsx("div",{className:"waterfall",children:i.jsxs("div",{className:"wf-row",children:[i.jsx("span",{className:"lbl",children:"e2e"}),i.jsx("span",{className:"track",children:i.jsx("span",{className:"seg-bar llm",style:{left:0,width:Math.min(100,n/1e3*100)+"%"}})}),i.jsx("span",{className:"v",children:n})]})}),i.jsxs("div",{className:"wf-legend",children:[i.jsxs("span",{children:[i.jsx("i",{style:{background:"#DF9367"}}),"end-to-end"]}),i.jsx("span",{style:{marginLeft:"auto"},children:e.agent??"realtime"})]})]})}const l=e.sttAvg||0,s=e.llmAvg||0,o=e.ttsAvg||0,u=l+s+o,a=Math.max(u,800),f=(e.turnCount??0)>=2;return i.jsxs(i.Fragment,{children:[i.jsxs("div",{className:"lat-grid",children:[i.jsxs("div",{className:"latbox",children:[i.jsx("div",{className:"l",children:"p50"}),i.jsxs("div",{className:"v",children:[f?e.latencyP50??"—":"—",f&&i.jsx("span",{className:"u",children:"ms"})]})]}),i.jsxs("div",{className:"latbox"+(f&&n>600?" warn":""),children:[i.jsx("div",{className:"l",children:"p95"}),i.jsxs("div",{className:"v",children:[f?n:"—",f&&i.jsx("span",{className:"u",children:"ms"})]})]}),i.jsxs("div",{className:"latbox",children:[i.jsx("div",{className:"l",children:"stt avg"}),i.jsxs("div",{className:"v",children:[e.sttAvg??"—",i.jsx("span",{className:"u",children:"ms"})]})]}),i.jsxs("div",{className:"latbox",children:[i.jsx("div",{className:"l",children:"tts avg"}),i.jsxs("div",{className:"v",children:[e.ttsAvg??"—",i.jsx("span",{className:"u",children:"ms"})]})]})]}),i.jsxs("div",{className:"waterfall",children:[i.jsxs("div",{className:"wf-row",children:[i.jsx("span",{className:"lbl",children:"stt"}),i.jsx("span",{className:"track",children:i.jsx("span",{className:"seg-bar stt",style:{left:0,width:l/a*100+"%"}})}),i.jsx("span",{className:"v",children:l})]}),i.jsxs("div",{className:"wf-row",children:[i.jsx("span",{className:"lbl",children:"llm"}),i.jsx("span",{className:"track",children:i.jsx("span",{className:"seg-bar llm",style:{left:l/a*100+"%",width:s/a*100+"%"}})}),i.jsx("span",{className:"v",children:s})]}),i.jsxs("div",{className:"wf-row",children:[i.jsx("span",{className:"lbl",children:"tts"}),i.jsx("span",{className:"track",children:i.jsx("span",{className:"seg-bar tts",style:{left:(l+s)/a*100+"%",width:o/a*100+"%"}})}),i.jsx("span",{className:"v",children:o})]})]}),i.jsxs("div",{className:"wf-legend",children:[i.jsxs("span",{children:[i.jsx("i",{style:{background:"#1a1a1a"}}),"stt"]}),i.jsxs("span",{children:[i.jsx("i",{style:{background:"#DF9367"}}),"llm"]}),i.jsxs("span",{children:[i.jsx("i",{style:{background:"#278EFF",opacity:.8}}),"tts"]}),i.jsxs("span",{style:{marginLeft:"auto"},children:["total ",u," ms"]})]})]})}function ss(e){if(e.length===0)return e;const t=e.replace(/(?:_(?:ws|rest|stt|tts|llm))+$/i,"");return t.charAt(0).toUpperCase()+t.slice(1)}function Wp({call:e}){const t=e.cost,n=t.telco??0,r=t.llm??0,l=t.stt??0,s=t.tts??0,o=t.sttTts??0,u=l===0&&s===0?o:0,a=t.cached??0,f=n+r+l+s+u,h=t.total??f-a,v=S=>f>0?S/f*100:0,m=e.sttProvider?`${ss(e.sttProvider)} STT${e.sttModel?` · ${e.sttModel}`:""}`:"STT",x=e.ttsProvider?`${ss(e.ttsProvider)} TTS${e.ttsModel?` · ${e.ttsModel}`:""}`:"TTS",w=e.llmModel?`${e.model?ss(e.model)+" · ":""}${e.llmModel}`:e.model||"LLM";return i.jsxs(i.Fragment,{children:[f>0&&i.jsxs("div",{className:"cost-bar",children:[i.jsx("i",{style:{background:"#cc0000",width:v(n)+"%"}}),i.jsx("i",{style:{background:"#DF9367",width:v(r)+"%"}}),i.jsx("i",{style:{background:"#1a1a1a",width:v(l+u)+"%"}}),i.jsx("i",{style:{background:"#6c6c6c",width:v(s)+"%"}})]}),n>0&&i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:[i.jsx("span",{className:"swatch",style:{background:"#cc0000"}}),e.carrier==="twilio"?"Twilio":"Telnyx"]}),i.jsx("span",{className:"v",children:De(n)})]}),r>0&&i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:[i.jsx("span",{className:"swatch",style:{background:"#DF9367"}}),w]}),i.jsx("span",{className:"v",children:De(r)}),a>0&&i.jsxs("span",{className:"saved",children:["−",De(a)," cached"]})]}),l>0&&i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:[i.jsx("span",{className:"swatch",style:{background:"#1a1a1a"}}),m]}),i.jsx("span",{className:"v",children:De(l)})]}),s>0&&i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:[i.jsx("span",{className:"swatch",style:{background:"#6c6c6c"}}),x]}),i.jsx("span",{className:"v",children:De(s)})]}),u>0&&i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:[i.jsx("span",{className:"swatch",style:{background:"#1a1a1a"}}),"STT / TTS (legacy)"]}),i.jsx("span",{className:"v",children:De(u)})]}),i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:["Total"," ",e.status==="live"&&i.jsx("span",{style:{fontFamily:"var(--font-mono)",fontSize:10,color:"#aaa",marginLeft:4},children:"(running)"})]}),i.jsx("span",{className:"v",children:De(h)})]})]})}const Ut=e=>typeof e=="object"&&e!==null&&!Array.isArray(e),Tt=e=>typeof e=="string"?e:"",Ae=e=>typeof e=="number"&&Number.isFinite(e)?e:0,ae=e=>typeof e=="number"&&Number.isFinite(e)?e:void 0,Xe=e=>typeof e=="string"&&e.length>0?e:void 0;function Pr(e){if(Ut(e))return{stt_ms:ae(e.stt_ms),llm_ms:ae(e.llm_ms),tts_ms:ae(e.tts_ms),total_ms:ae(e.total_ms),agent_response_ms:ae(e.agent_response_ms),endpoint_ms:ae(e.endpoint_ms),user_speech_duration_ms:ae(e.user_speech_duration_ms)}}function Qp(e){if(Ut(e))return{stt:ae(e.stt),tts:ae(e.tts),llm:ae(e.llm),telephony:ae(e.telephony),total:ae(e.total),llm_cached_savings:ae(e.llm_cached_savings)}}function Kp(e){if(!Ut(e))return null;const t=e.turns;return{duration_seconds:ae(e.duration_seconds),provider_mode:Xe(e.provider_mode),telephony_provider:Xe(e.telephony_provider),stt_provider:Xe(e.stt_provider),tts_provider:Xe(e.tts_provider),llm_provider:Xe(e.llm_provider),stt_model:Xe(e.stt_model),tts_model:Xe(e.tts_model),llm_model:Xe(e.llm_model),cost:Qp(e.cost),latency_avg:Pr(e.latency_avg),latency_p50:Pr(e.latency_p50),latency_p95:Pr(e.latency_p95),latency_p99:Pr(e.latency_p99),turns:Array.isArray(t)?t:void 0}}function Yp(e){if(!Array.isArray(e))return;const t=[];for(const n of e)Ut(n)&&t.push({role:Tt(n.role),text:Tt(n.text),timestamp:Ae(n.timestamp)});return t}function Pc(e){if(!Ut(e))return null;const t=Tt(e.call_id);if(t.length===0)return null;const n=e.turns;return{call_id:t,caller:Tt(e.caller),callee:Tt(e.callee),direction:Tt(e.direction),started_at:Ae(e.started_at),ended_at:ae(e.ended_at),status:Xe(e.status),transcript:Yp(e.transcript),turns:Array.isArray(n)?n:void 0,metrics:Kp(e.metrics)}}function Tc(e){if(!Array.isArray(e))return[];const t=[];for(const n of e){const r=Pc(n);r&&t.push(r)}return t}function Xp(e){return Ut(e)?{stt:Ae(e.stt),tts:Ae(e.tts),llm:Ae(e.llm),telephony:Ae(e.telephony)}:{stt:0,tts:0,llm:0,telephony:0}}function Gp(e){if(!Ut(e))return{total_calls:0,total_cost:0,avg_duration:0,avg_latency_ms:0,cost_breakdown:{stt:0,tts:0,llm:0,telephony:0},active_calls:0};const t=Tt(e.sdk_version);return{total_calls:Ae(e.total_calls),total_cost:Ae(e.total_cost),avg_duration:Ae(e.avg_duration),avg_latency_ms:Ae(e.avg_latency_ms),cost_breakdown:Xp(e.cost_breakdown),active_calls:Ae(e.active_calls),...t?{sdk_version:t}:{}}}async function Jo(e){const t=await fetch(e,{headers:{Accept:"application/json"}});if(!t.ok)throw new Error(`Request to ${e} failed with status ${t.status}`);return t.json()}async function Zp(e=50,t=0){const n=`/api/dashboard/calls?limit=${encodeURIComponent(e)}&offset=${encodeURIComponent(t)}`,r=await Jo(n);return Tc(r)}async function Jp(){const e=await Jo("/api/dashboard/active");return Tc(e)}async function qp(){const e=await Jo("/api/dashboard/aggregates");return Gp(e)}async function bp(e){const t=`/api/dashboard/calls/${encodeURIComponent(e)}`,n=await fetch(t,{headers:{Accept:"application/json"}});if(n.status===404)return null;if(!n.ok)throw new Error(`Request to ${t} failed with status ${n.status}`);const r=await n.json();return Pc(r)}async function eh(e){if(e.length===0)return[];if(e.length===1){const r=`/api/dashboard/calls/${encodeURIComponent(e[0])}`,l=await fetch(r,{method:"DELETE",headers:{Accept:"application/json"}});if(!l.ok)throw new Error(`DELETE ${r} failed with status ${l.status}`);const s=await l.json();return Array.isArray(s.deleted)?s.deleted.filter(o=>typeof o=="string"):[]}const t=await fetch("/api/dashboard/calls/delete",{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({call_ids:e})});if(!t.ok)throw new Error(`POST /api/dashboard/calls/delete failed with status ${t.status}`);const n=await t.json();return Array.isArray(n.deleted)?n.deleted.filter(r=>typeof r=="string"):[]}const th=new Set(["in-progress","initiated"]);function nh(e){if(!e)return"ended";switch(e){case"in-progress":case"initiated":return"live";case"completed":return"ended";case"no-answer":return"no-answer";case"busy":case"failed":case"canceled":case"webhook_error":return"fail";default:return"ended"}}function rh(e){return e==="outbound"?"outbound":"inbound"}function lh(e){return typeof e=="string"&&e.toLowerCase().includes("telnyx")?"telnyx":"twilio"}function sh(e){if(typeof e!="string")return"unknown";const t=e.toLowerCase();return t.includes("realtime")?"realtime":t.includes("convai")?"convai":t.includes("pipeline")?"pipeline":"unknown"}function au(e){return e.length===0?"—":e}function oh(e){const t=e.metrics?.provider_mode;if(!t)return;const n=e.metrics?.llm_provider;return t.startsWith("pipeline")&&n?`${t} · ${n}`:t}function ih(e){const t=e.metrics?.cost;if(!t)return{};const n={};return typeof t.telephony=="number"&&(n.telco=t.telephony),typeof t.llm=="number"&&(n.llm=t.llm),typeof t.stt=="number"&&(n.stt=t.stt),typeof t.tts=="number"&&(n.tts=t.tts),typeof t.llm_cached_savings=="number"&&(n.cached=t.llm_cached_savings),(n.stt!==void 0||n.tts!==void 0)&&(n.sttTts=(n.stt??0)+(n.tts??0)),n.telco===void 0&&n.llm===void 0&&n.sttTts===void 0&&typeof t.total=="number"&&(n.total=t.total),n}function uh(e,t){if(t)return;const n=e.metrics?.duration_seconds;return typeof n=="number"?n:typeof e.ended_at=="number"&&typeof e.started_at=="number"?Math.max(0,e.ended_at-e.started_at):0}function ah(e){if(typeof e.ended_at=="number")return Math.round(Date.now()/1e3-e.ended_at)}function cu(e){const t=nh(e.status),n=t==="live"||e.status!==void 0&&th.has(e.status),r=e.metrics?.latency_avg,l=e.metrics?.latency_p50,s=e.metrics?.latency_p95,o=(Array.isArray(e.metrics?.turns)?e.metrics?.turns?.length:void 0)??(Array.isArray(e.transcript)?e.transcript.length:void 0);return{id:e.call_id,status:t,direction:rh(e.direction),from:au(e.caller),to:au(e.callee),carrier:lh(e.metrics?.telephony_provider),startedAtMs:typeof e.started_at=="number"?e.started_at*1e3:void 0,durationStart:n?e.started_at*1e3:void 0,duration:uh(e,n),latencyP95:s?.agent_response_ms??s?.total_ms??r?.total_ms,latencyP50:l?.agent_response_ms??l?.total_ms??r?.total_ms,sttAvg:r?.stt_ms,ttsAvg:r?.tts_ms,llmAvg:r?.llm_ms,turnCount:o,agentResponseP50:l?.agent_response_ms,agentResponseP95:s?.agent_response_ms,cost:ih(e),agent:oh(e),model:e.metrics?.llm_provider,mode:sh(e.metrics?.provider_mode),sttProvider:e.metrics?.stt_provider,ttsProvider:e.metrics?.tts_provider,sttModel:e.metrics?.stt_model,ttsModel:e.metrics?.tts_model,llmModel:e.metrics?.llm_model,transcriptKey:e.call_id,endedAgo:ah(e)}}function ch(e){const t=e.transcript;if(t&&t.length>0){const l=[];for(const s of t){const o=s.text;switch(s.role){case"user":l.push({who:"user",txt:o});break;case"assistant":l.push({who:"bot",txt:o});break;case"tool":l.push({who:"tool",txt:o});break;default:l.push({who:"bot",txt:o});break}}return l}const n=e.turns;if(!n||n.length===0)return[];const r=[];for(const l of n){if(typeof l!="object"||l===null)continue;const s=l,o=typeof s.user_text=="string"?s.user_text:"",u=typeof s.agent_text=="string"?s.agent_text:"";o.length>0&&r.push({who:"user",txt:o}),u.length>0&&u!=="[interrupted]"&&r.push({who:"bot",txt:u})}return r}const zc=60*1e3,Rc=60*zc,os=24*Rc;function fh(e,t=Date.now()){switch(e){case"1h":{const n=5*zc,r=Math.ceil(t/n)*n,l=r-12*n;return{count:12,bucketSizeMs:n,window:{fromMs:l,toMs:r}}}case"24h":{const n=Rc,r=Math.ceil(t/n)*n,l=r-24*n;return{count:24,bucketSizeMs:n,window:{fromMs:l,toMs:r}}}case"7d":{const n=new Date(t);n.setHours(0,0,0,0);const r=n.getTime()+os,l=r-7*os;return{count:7,bucketSizeMs:os,window:{fromMs:l,toMs:r}}}case"All":default:return{count:9,bucketSizeMs:0,window:{fromMs:0,toMs:t}}}}function dh(e,t){const{fromMs:n,toMs:r}=t;return e.filter(l=>{const s=to(l);return typeof s!="number"?!1:s>=n&&s<=r})}function to(e){if(typeof e.startedAtMs=="number")return e.startedAtMs;if(typeof e.durationStart=="number")return e.durationStart;if(typeof e.endedAgo=="number")return Date.now()-e.endedAgo*1e3}function ph(e){const t=e.cost,n=(t.telco??0)+(t.llm??0)+(t.sttTts??0);return n>0?n:t.total??0}function hh(e){const t=e.reduce((n,r)=>r>n?r:n,0);return t<=0?e.map(()=>0):e.map(n=>Math.round(n/t*100))}function Tr(e,t,n=9,r){const l=typeof n=="object",s=l?n.count:n,o=Math.max(1,Math.floor(s)),u=l?n.window:r,a=l?n.bucketSizeMs:0;let f,h;if(u)f=u.fromMs,h=u.toMs;else{const d=[];for(const c of e){const p=to(c);typeof p=="number"&&d.push(p)}if(d.length===0){const c=Date.now();return{heights:new Array(o).fill(0),buckets:new Array(o).fill(null).map(()=>[]),window:{fromMs:c,toMs:c},bucketSizeMs:0}}f=Math.min(...d),h=Math.max(...d)}const v=Math.max(1,h-f),m=a>0?a:v/o,x=new Array(o).fill(null).map(()=>[]),w=new Array(o).fill(0),S=new Array(o).fill(0);for(const d of e){const c=to(d);if(typeof c!="number"||ch)continue;let p=Math.floor((c-f)/m);p>=o&&(p=o-1),p<0&&(p=0),x[p].push(d),t==="totalCalls"?w[p]+=1:t==="latency"?typeof d.latencyP95=="number"&&(w[p]+=d.latencyP95,S[p]+=1):w[p]+=ph(d)}const T=t==="latency"?w.map((d,c)=>S[c]>0?d/S[c]:0):w;return{heights:hh(T),buckets:x,window:{fromMs:f,toMs:h},bucketSizeMs:m}}const mh=500;function vh(e,t){const n=new Set,r=[];for(const l of e)n.has(l.call_id)||(n.add(l.call_id),r.push(cu(l)));for(const l of t)n.has(l.call_id)||(n.add(l.call_id),r.push(cu(l)));return r}function yh(e,t){const n=new Map(e.map(s=>[s.id,s])),r=new Set(t.map(s=>s.id)),l=t.map(s=>{const o=n.get(s.id);return o?{...o,...s,latencyP95:s.latencyP95??o.latencyP95,latencyP50:s.latencyP50??o.latencyP50,sttAvg:s.sttAvg??o.sttAvg,ttsAvg:s.ttsAvg??o.ttsAvg,llmAvg:s.llmAvg??o.llmAvg,turnCount:s.turnCount??o.turnCount,agentResponseP50:s.agentResponseP50??o.agentResponseP50,agentResponseP95:s.agentResponseP95??o.agentResponseP95,cost:{...o.cost,...s.cost}}:s});for(const s of e)r.has(s.id)||l.push(s);return l.sort((s,o)=>(o.startedAtMs??0)-(s.startedAtMs??0)),l.slice(0,mh)}const gh=1e3,wh=3e4,xh=5,kh=5e3,Sh=["call_start","call_initiated","call_status","call_end","calls_deleted"];function fu(e){return e instanceof Error?e.message:"Unknown error"}function Ch(){const[e,t]=M.useState([]),[n,r]=M.useState(null),[l,s]=M.useState(!1),[o,u]=M.useState(null),a=M.useRef(!0),f=M.useRef(null),h=M.useRef(null),v=M.useRef(null),m=M.useRef(0),x=M.useCallback(()=>{h.current!==null&&(clearTimeout(h.current),h.current=null)},[]),w=M.useCallback(()=>{v.current!==null&&(clearInterval(v.current),v.current=null)},[]),S=M.useCallback(()=>{f.current!==null&&(f.current.close(),f.current=null)},[]),T=M.useCallback(async()=>{try{const[g,j,D]=await Promise.all([Jp(),Zp(50,0),qp()]);if(!a.current)return;t(L=>yh(L,vh(g,j))),r(D),u(null)}catch(g){if(!a.current)return;u(fu(g))}},[]),d=M.useCallback(()=>{v.current===null&&(v.current=setInterval(()=>{T()},kh))},[T]),c=M.useRef(()=>{}),p=M.useCallback(()=>{if(x(),m.current>=xh){d();return}const g=m.current,j=Math.min(wh,gh*Math.pow(2,g));m.current=g+1,h.current=setTimeout(()=>{h.current=null,a.current&&c.current()},j)},[x,d]),y=M.useCallback(()=>{T()},[T]),_=M.useCallback(()=>{S();let g;try{g=new EventSource("/api/dashboard/events")}catch(j){u(fu(j)),p();return}f.current=g,g.onopen=()=>{a.current&&(m.current=0,w(),s(!0))},g.onerror=()=>{a.current&&(s(!1),S(),p())};for(const j of Sh)g.addEventListener(j,y);g.addEventListener("turn_complete",y)},[S,w,y,p]);M.useEffect(()=>{c.current=_},[_]),M.useEffect(()=>(a.current=!0,T(),_(),()=>{a.current=!1,x(),w(),S()}),[]);const C=M.useCallback(g=>{if(g.length===0)return;const j=new Set(g);t(D=>D.filter(L=>!j.has(L.id)))},[]);return{calls:e,aggregates:n,isStreaming:l,error:o,refresh:T,removeCallsLocal:C}}const jh=2e3;function _h(e,t){const[n,r]=M.useState([]),l=M.useRef(!0);return M.useEffect(()=>(l.current=!0,()=>{l.current=!1}),[]),M.useEffect(()=>{if(!e){r([]);return}let s=!1,o=null,u=null;const a=async()=>{try{const h=await bp(e);if(s||!l.current)return;if(h===null){r([]);return}r(ch(h))}catch{}};a();const f=h=>{const v=h;try{return JSON.parse(v.data)?.call_id===e}catch{return!1}};try{u=new EventSource("/api/dashboard/events"),u.addEventListener("turn_complete",h=>{f(h)&&a()}),u.addEventListener("call_end",h=>{f(h)&&a()})}catch{u=null}return t&&(o=setInterval(()=>{a()},jh)),()=>{s=!0,o!==null&&clearInterval(o),u!==null&&u.close()}},[e,t]),n}const du="patter.dashboard.reveal",Dc="patter.dashboard.theme";function Nh(e,t){try{const n=window.localStorage.getItem(e);return n==="1"||n==="true"?!0:n==="0"||n==="false"?!1:t}catch{return t}}function Eh(){try{const e=window.localStorage.getItem(Dc);if(e==="dark")return"dark";if(e==="light")return"light"}catch{}return"light"}function Mh(){const[e,t]=M.useState(()=>Nh(du,!1)),[n,r]=M.useState(()=>Eh());M.useEffect(()=>{try{window.localStorage.setItem(du,e?"1":"0")}catch{}},[e]),M.useEffect(()=>{try{window.localStorage.setItem(Dc,n)}catch{}const o=document.body.classList;n==="dark"?o.add("dark"):o.remove("dark")},[n]);const l=M.useCallback(()=>{t(o=>!o)},[]),s=M.useCallback(()=>{r(o=>o==="dark"?"light":"dark")},[]);return{revealed:e,dark:n==="dark",toggleRevealed:l,toggleDark:s}}const Lh="dev",is={"1h":"1h","24h":"24h","7d":"7d",All:"all-time"};function Ph(e){const t=e.filter(r=>typeof r.latencyP95=="number");if(t.length===0)return 0;const n=t.reduce((r,l)=>r+(l.latencyP95??0),0);return Math.round(n/t.length)}function Th(e){return e.reduce((t,n)=>{if(typeof n.cost.total=="number")return t+n.cost.total;const r=(n.cost.telco??0)+(n.cost.llm??0)+(n.cost.sttTts??0);return t+r},0)}function zh(e){const n=e.find(l=>l.status==="live")??e[0];if(!n)return"";const r=n.direction==="inbound"?n.to:n.from;return r&&r!=="—"?r:""}function Rh(){const{calls:e,aggregates:t,isStreaming:n,error:r,refresh:l,removeCallsLocal:s}=Ch(),{revealed:o,dark:u,toggleRevealed:a,toggleDark:f}=Mh(),[h,v]=M.useState(null),[m,x]=M.useState(""),[w,S]=M.useState("24h"),[T,d]=M.useState(!0),[c,p]=M.useState(!1),y=M.useMemo(()=>fh(w),[w]),_=y.window,C=M.useMemo(()=>{if(w==="All")return e;const I=new Set(dh(e,_).map(H=>H.id));return e.filter(H=>H.status==="live"||I.has(H.id))},[e,w,_]);M.useEffect(()=>{if(h!==null)return;const I=C.find(H=>H.status==="live")??C[0];I&&v(I.id)},[C,h]),M.useEffect(()=>{h!==null&&(C.some(I=>I.id===h)||v(null))},[C,h]),M.useEffect(()=>{const I=H=>{if(!(H.shiftKey&&H.key.toLowerCase()==="k"||H.metaKey&&H.key.toLowerCase()==="k"))return;H.preventDefault(),document.querySelector(".panel-h .search input")?.focus()};return window.addEventListener("keydown",I),()=>window.removeEventListener("keydown",I)},[]);const g=M.useMemo(()=>C.find(I=>I.id===h)??null,[C,h]),j=g?.status==="live",D=_h(g?.id??null,j),L=M.useMemo(()=>e.filter(I=>I.status==="live").length,[e]),pe=M.useMemo(()=>e.filter(I=>I.status==="live"&&I.direction==="inbound").length,[e]),_t=L-pe,Qe=C.length,cr=Ph(C)||t?.avg_latency_ms||0,zl=Th(C)||t?.total_cost||0,xn=zh(e),Ht=typeof t?.sdk_version=="string"&&t.sdk_version||Lh,N=M.useMemo(()=>Tr(C,"totalCalls",y),[C,y]),P=M.useMemo(()=>Tr(C,"latency",y),[C,y]),z=M.useMemo(()=>Tr(C,"spend",y),[C,y]),U=M.useMemo(()=>{const I=e.filter(H=>H.status==="live");return Tr(I,"totalCalls",y)},[e,y]),W=I=>I.heights.map((H,Ye)=>({height:H,calls:I.buckets[Ye],fromMs:I.window.fromMs+Ye*I.bucketSizeMs,toMs:I.window.fromMs+(Ye+1)*I.bucketSizeMs})),Bt=()=>{g&&l().catch(()=>{})},Ke=async I=>{if(I.length!==0){s(I),I.includes(h??"")&&v(null);try{await eh(I)}catch{await l().catch(()=>{})}}};return i.jsxs(i.Fragment,{children:[i.jsx(_p,{liveCount:L,todayCount:Qe,phoneNumber:xn,sdkVersion:Ht,revealed:o,dark:u,onToggleRevealed:a,onToggleDark:f}),i.jsxs("div",{className:"page",children:[i.jsx(Mp,{range:w,setRange:I=>S(I)}),i.jsxs("div",{className:"metrics",children:[i.jsx(Lr,{label:`Calls · ${is[w]}`,value:Qe,spark:N.heights,buckets:W(N),onSelectCall:v,kind:"count"}),i.jsx(Lr,{label:"Avg latency p95",value:cr||0,unit:"ms",spark:P.heights,buckets:W(P),onSelectCall:v,kind:"latency"}),i.jsx(Lr,{label:`Spend · ${is[w]}`,value:De(zl),spark:z.heights,buckets:W(z),onSelectCall:v,kind:"spend"}),i.jsx(Lr,{label:"Active now",value:L,peach:!0,badge:!0,footer:`${pe} inbound · ${_t} outbound`,spark:U.heights,buckets:W(U),onSelectCall:v,kind:"count"})]}),i.jsxs("div",{className:"split",children:[i.jsx(Op,{calls:C,selectedId:h,onSelect:v,newId:null,search:m,setSearch:x,onDeleteCalls:Ke,revealed:o}),i.jsxs("div",{className:"rr",children:[i.jsx($p,{call:g,transcript:D,onEnd:Bt,recording:T,setRecording:d,muted:c,setMuted:p,revealed:o}),i.jsx(Hp,{call:g})]})]}),i.jsxs("div",{className:"statusbar",children:[i.jsxs("div",{className:"group",children:[i.jsx("span",{className:n?"green":"",children:n?"streaming · sse":r?`error · ${r}`:"idle"}),i.jsxs("span",{children:["SDK · ",Ht]})]}),i.jsx("div",{className:"group",children:i.jsxs("span",{children:[L," live · ",Qe," ",is[w]]})})]})]})]})}const Ic=document.getElementById("root");if(!Ic)throw new Error("Patter dashboard: #root element missing");us.createRoot(Ic).render(i.jsx(qc.StrictMode,{children:i.jsx(Rh,{})})); diff --git a/libraries/python/getpatter/engines/__init__.py b/libraries/python/getpatter/engines/__init__.py index 02fe530e..21e588d3 100644 --- a/libraries/python/getpatter/engines/__init__.py +++ b/libraries/python/getpatter/engines/__init__.py @@ -12,4 +12,4 @@ from __future__ import annotations -__all__ = ["openai", "elevenlabs"] +__all__ = ["openai", "openai_realtime_2", "elevenlabs"] diff --git a/libraries/python/getpatter/engines/openai.py b/libraries/python/getpatter/engines/openai.py index d3bb237f..bc33de62 100644 --- a/libraries/python/getpatter/engines/openai.py +++ b/libraries/python/getpatter/engines/openai.py @@ -31,7 +31,7 @@ class Realtime: api_key: str = "" voice: str = "alloy" - model: str = "gpt-4o-mini-realtime-preview" + model: str = "gpt-realtime-mini" # Reasoning-effort tier for ``gpt-realtime-2``. ``None`` leaves the # ``session.reasoning`` field unset (server default applies). OpenAI # recommends ``"low"`` for production voice flows — higher tiers add diff --git a/libraries/python/getpatter/engines/openai_realtime_2.py b/libraries/python/getpatter/engines/openai_realtime_2.py new file mode 100644 index 00000000..fefcb885 --- /dev/null +++ b/libraries/python/getpatter/engines/openai_realtime_2.py @@ -0,0 +1,64 @@ +"""OpenAI Realtime 2 engine marker for Patter. + +Wraps ``gpt-realtime-2`` (GA Realtime API). Separate marker from +:class:`getpatter.engines.openai.Realtime` because the GA endpoint speaks a +different ``session.update`` wire shape; the client dispatches to +:class:`getpatter.providers.openai_realtime_2.OpenAIRealtime2Adapter` when +this marker is passed to ``Patter.agent(engine=...)``. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Literal + +__all__ = ["Realtime2"] + + +@dataclass(frozen=True) +class Realtime2: + """OpenAI GA Realtime API engine config — selects ``gpt-realtime-2``. + + Holds the minimal settings needed by the Patter server to instantiate + :class:`getpatter.providers.openai_realtime_2.OpenAIRealtime2Adapter` at + call time. + + Example:: + + from getpatter import Patter, Twilio, OpenAIRealtime2 + + phone = Patter(carrier=Twilio(), phone_number="+1...") + agent = phone.agent( + engine=OpenAIRealtime2(reasoning_effort="low"), + system_prompt="You are a friendly receptionist.", + first_message="Hello! How can I help?", + ) + """ + + api_key: str = "" + voice: str = "alloy" + model: str = "gpt-realtime-2" + # Reasoning-effort tier. ``None`` leaves the field unset (server default + # applies). OpenAI recommends ``"low"`` for production voice flows — + # higher tiers add measurable per-turn latency. Has no effect on models + # that don't support the ``reasoning`` field. + reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None + # Override for ``audio.input.transcription.model``. ``None`` keeps the + # adapter default (``whisper-1``). Use ``"gpt-realtime-whisper"`` for + # low-latency transcript partials. + input_audio_transcription_model: str | None = None + + def __post_init__(self) -> None: + key = self.api_key or os.environ.get("OPENAI_API_KEY", "") + if not key: + raise ValueError( + "OpenAI Realtime 2 engine requires an api_key. Pass " + "api_key='sk-...' or set OPENAI_API_KEY in the environment." + ) + object.__setattr__(self, "api_key", key) + + @property + def kind(self) -> str: + """Stable discriminator used for engine dispatch.""" + return "openai_realtime_2" diff --git a/libraries/python/getpatter/models.py b/libraries/python/getpatter/models.py index e3c036cc..8fead1d0 100644 --- a/libraries/python/getpatter/models.py +++ b/libraries/python/getpatter/models.py @@ -214,14 +214,22 @@ class Agent: # call. See ``docs/python-sdk/latency.mdx`` for the cold-start latency # rationale. prewarm: bool = True - # When ``True`` (default ``False``), ``Patter.call`` also pre-renders - # ``first_message`` to TTS audio bytes during the ringing window and - # streams the cached buffer immediately when the carrier emits ``start``. - # Eliminates the 200-700 ms TTS first-byte latency on the greeting at the - # cost of paying the TTS bill even if the call is never answered (silently - # logged at WARN level when the call fails). Off by default to preserve - # the prior cost surface; opt-in for production outbound where every - # millisecond of greeting latency hurts conversion. + # When ``True``, ``Patter.call`` pre-renders ``first_message`` to TTS + # audio bytes during the ringing window and streams the cached buffer + # immediately when the carrier emits ``start``. Eliminates the + # 200-700 ms TTS first-byte latency on the greeting that dominates + # the first-turn ``p95`` on pipeline calls. + # + # Dataclass default stays ``False`` to preserve backwards-compatible + # behaviour for callers who construct ``Agent(...)`` directly without + # going through :meth:`Patter.agent`. The recommended factory + # :meth:`Patter.agent` flips the default to ``True`` automatically + # when ``provider == "pipeline"`` (since 0.6.2) — parity with the + # TypeScript ``phone.agent({...})`` factory. Opt out from the factory + # by passing ``prewarm_first_message=False`` (e.g. for very + # high-volume outbound where un-answered TTS spend matters); cost + # trade-off is typically $0.001-$0.005 per ringing call depending on + # TTS provider. # # **Pipeline mode only.** Realtime / ConvAI provider modes never # consume the prewarm cache (the StreamHandler for those modes runs diff --git a/libraries/python/getpatter/providers/elevenlabs_tts.py b/libraries/python/getpatter/providers/elevenlabs_tts.py index 0e226680..6adbb604 100644 --- a/libraries/python/getpatter/providers/elevenlabs_tts.py +++ b/libraries/python/getpatter/providers/elevenlabs_tts.py @@ -168,9 +168,7 @@ def __init__( api_key: str, voice_id: str = "21m00Tcm4TlvDq8ikWAM", model_id: Union[ElevenLabsModel, str] = ElevenLabsModel.FLASH_V2_5, - output_format: Union[ - ElevenLabsOutputFormat, str - ] = ElevenLabsOutputFormat.PCM_16000, + output_format: Union[ElevenLabsOutputFormat, str, None] = None, voice_settings: Optional[dict] = None, language_code: Optional[str] = None, chunk_size: int = 4096, @@ -178,7 +176,20 @@ def __init__( self.api_key = api_key self.voice_id = resolve_voice_id(voice_id) self.model_id = model_id - self.output_format = output_format + # Track whether the caller explicitly chose an ``output_format``. When + # left unset (``None``), we default to PCM 16 kHz for backward-compat + # but allow ``set_telephony_carrier`` to auto-flip to the carrier's + # native format (``ulaw_8000`` for Twilio) so ElevenLabs encodes + # server-side and we skip a client-side mulaw transcode. When the + # caller passed an explicit value, ``set_telephony_carrier`` is a + # no-op — the user's choice is respected. + # Parity with ElevenLabsWebSocketTTS._output_format_explicit. + self._output_format_explicit = output_format is not None + self.output_format: Union[ElevenLabsOutputFormat, str] = ( + output_format + if output_format is not None + else ElevenLabsOutputFormat.PCM_16000 + ) self.voice_settings = voice_settings self.language_code = language_code self.chunk_size = chunk_size @@ -268,6 +279,36 @@ def for_telnyx( chunk_size=chunk_size, ) + # Map of telephony carrier → ElevenLabs HTTP-native ``output_format`` for + # zero-transcode delivery to the carrier wire. Mirrors the WS variant's + # ``_CARRIER_NATIVE_FORMAT`` so both adapters behave identically when the + # stream-handler calls ``set_telephony_carrier``. + _CARRIER_NATIVE_FORMAT: dict = { + "twilio": ElevenLabsOutputFormat.ULAW_8000, + "telnyx": ElevenLabsOutputFormat.PCM_16000, + } + + def set_telephony_carrier(self, carrier: str) -> None: + """Hook called by ``StreamHandler`` to advise the carrier wire format. + + When the user did NOT pass an explicit ``output_format`` to + ``__init__``, this flips the format to the carrier's native wire + codec — saving a client-side transcode step. Calling with an + unknown carrier (``""`` / ``"custom"``) is a no-op. + + When ``output_format`` was explicitly passed (incl. via the + ``for_twilio`` / ``for_telnyx`` factories), this method is a no-op + — the user's choice always wins. + + Parity with :meth:`ElevenLabsWebSocketTTS.set_telephony_carrier`. + """ + if self._output_format_explicit: + return + native = self._CARRIER_NATIVE_FORMAT.get(carrier) + if native is None: + return + self.output_format = native + def _record_synthesis_cost(self, text: str) -> None: """Emit ``patter.cost.tts_chars`` for the synthesised text.""" try: diff --git a/libraries/python/getpatter/providers/elevenlabs_ws_tts.py b/libraries/python/getpatter/providers/elevenlabs_ws_tts.py index 426d523b..ae1f969f 100644 --- a/libraries/python/getpatter/providers/elevenlabs_ws_tts.py +++ b/libraries/python/getpatter/providers/elevenlabs_ws_tts.py @@ -226,6 +226,13 @@ def __init__( # send) instead of opening a fresh socket. The slot is # consumed exactly once. self._adopted_connection: Optional[ElevenLabsParkedWS] = None + # Holds a reference to the currently open synthesis WebSocket so + # ``cancel_active_stream`` can force-close it from outside the + # generator. Set just before the ``while True`` receive loop in + # ``synthesize``; cleared in the generator's ``finally`` block. + # ``None`` when no synthesis is in progress. + # Parity with TS ``ElevenLabsWebSocketTTS.activeStreamWs``. + self._active_stream_ws: object = None @property def api_key(self) -> str: @@ -338,6 +345,44 @@ def set_telephony_carrier(self, carrier: str) -> None: return self.output_format = native + def cancel_active_stream(self) -> None: + """Force-close the currently open synthesis WebSocket (if any). + + Called by ``StreamHandler`` from ``_do_cancel_for_barge_in``, + ``on_stop``, and ``on_ws_close`` to unblock the in-flight + ``synthesize`` generator's ``await ws.recv()`` immediately. + Without this the generator stays blocked in the receive loop + for up to ``frame_timeout`` (default 30 s) — ``_init_pipeline`` + would never return, the STT ``on_transcript`` callback would + never register, and every subsequent user turn would be silently + dropped. + + No-op when no synthesis is in progress. Thread-safe: the close + is idempotent on an already-closed websocket. + + Parity with TS ``ElevenLabsWebSocketTTS.cancelActiveStream``. + """ + ws = self._active_stream_ws + if ws is None: + return + self._active_stream_ws = None + try: + # ``websockets`` connection objects are asyncio-aware; close() + # schedules the close on the running event loop. We fire-and- + # forget here because cancel_active_stream is called from sync + # context (signal handler / barge-in cancel path). + import asyncio + + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.call_soon_threadsafe( + lambda: asyncio.ensure_future(ws.close()) # type: ignore[attr-defined] + ) + else: + asyncio.run(ws.close()) # type: ignore[attr-defined] + except Exception: + pass # defensive — socket may already be closed + # ------------------------------------------------------------------ # Streaming # ------------------------------------------------------------------ @@ -445,6 +490,12 @@ async def synthesize(self, text: str) -> AsyncGenerator[bytes, None]: from websockets.exceptions import ConnectionClosedOK + # Expose the in-flight WS so ``cancel_active_stream`` (called + # from the stream-handler barge-in / stop / ws-close paths) can + # force-close it and unblock the ``await ws.recv()`` below. + # Parity with TS ``ElevenLabsWebSocketTTS.activeStreamWs``. + self._active_stream_ws = ws + while True: try: raw = await asyncio.wait_for(ws.recv(), timeout=self.frame_timeout) @@ -507,6 +558,11 @@ async def synthesize(self, text: str) -> AsyncGenerator[bytes, None]: if msg.get("isFinal"): return finally: + # Clear the active-stream slot. A concurrent ``cancel_active_stream`` + # call may have already set it to None; that is safe — ``ws`` is a + # local binding and the close below is idempotent. + if self._active_stream_ws is ws: + self._active_stream_ws = None # Best-effort: tell the server to stop synthesising any # buffered text the consumer is no longer interested in. # Failure to send is non-fatal — the socket close below diff --git a/libraries/python/getpatter/providers/openai_realtime.py b/libraries/python/getpatter/providers/openai_realtime.py index d9462b38..657b2851 100644 --- a/libraries/python/getpatter/providers/openai_realtime.py +++ b/libraries/python/getpatter/providers/openai_realtime.py @@ -240,7 +240,6 @@ async def warmup(self) -> None: url, additional_headers={ "Authorization": f"Bearer {self.api_key}", - "OpenAI-Beta": "realtime=v1", }, ping_interval=20, ping_timeout=20, @@ -345,7 +344,6 @@ async def connect(self) -> None: url, additional_headers={ "Authorization": f"Bearer {self.api_key}", - "OpenAI-Beta": "realtime=v1", }, # Keep the connection alive on long conversational pauses; a # dropped WS mid-call is the single most common failure on @@ -360,7 +358,14 @@ async def connect(self) -> None: response = await self._ws.recv() data = json.loads(response) if data.get("type") != "session.created": - raise RuntimeError(f"Expected session.created, got {data.get('type')}") + # Surface the actual server-side error so callers see the + # OpenAI message (auth, model not available, quota, etc.) + # rather than the bare "got error" wrapper. + err = data.get("error") or {} + msg = err.get("message") or err.get("code") or str(data) + raise RuntimeError( + f"Expected session.created, got {data.get('type')!r}: {msg}" + ) await self._ws.send( json.dumps( @@ -404,7 +409,6 @@ async def open_parked_connection(self): # type: ignore[no-untyped-def] url, additional_headers={ "Authorization": f"Bearer {self.api_key}", - "OpenAI-Beta": "realtime=v1", }, ping_interval=20, ping_timeout=20, @@ -415,7 +419,11 @@ async def open_parked_connection(self): # type: ignore[no-untyped-def] response = await asyncio.wait_for(ws.recv(), timeout=2.0) data = json.loads(response) if data.get("type") != "session.created": - raise RuntimeError(f"Expected session.created, got {data.get('type')}") + err = data.get("error") or {} + msg = err.get("message") or err.get("code") or str(data) + raise RuntimeError( + f"Expected session.created, got {data.get('type')!r}: {msg}" + ) await ws.send( json.dumps( { @@ -644,28 +652,35 @@ async def cancel_response(self) -> None: """ if self._ws is None: return - if self._current_response_item_id: - audio_end_ms = self._current_response_audio_ms - if self._current_response_first_audio_at is not None: - # Cap by wall-clock playback time. Subtracting from the - # generated total keeps audio_end_ms ≥ 0 and ≤ generated_ms. - elapsed_ms = int( - (time.monotonic() - self._current_response_first_audio_at) * 1000 - ) - audio_end_ms = min(audio_end_ms, max(elapsed_ms, 0)) - try: - await self._ws.send( - json.dumps( - { - "type": "conversation.item.truncate", - "item_id": self._current_response_item_id, - "content_index": 0, - "audio_end_ms": audio_end_ms, - } - ) + if not self._current_response_item_id: + # No response in flight — nothing to cancel. OpenAI Realtime + # GA rejects unconditional ``response.cancel`` with + # ``response_cancel_not_active``, which surfaces as ERROR-level + # log spam on every phantom VAD ``speech_started`` (echo of + # agent audio, voicemail beep, line noise). Silent no-op here + # keeps the cancel idempotent across stale callers. + return + audio_end_ms = self._current_response_audio_ms + if self._current_response_first_audio_at is not None: + # Cap by wall-clock playback time. Subtracting from the + # generated total keeps audio_end_ms ≥ 0 and ≤ generated_ms. + elapsed_ms = int( + (time.monotonic() - self._current_response_first_audio_at) * 1000 + ) + audio_end_ms = min(audio_end_ms, max(elapsed_ms, 0)) + try: + await self._ws.send( + json.dumps( + { + "type": "conversation.item.truncate", + "item_id": self._current_response_item_id, + "content_index": 0, + "audio_end_ms": audio_end_ms, + } ) - except Exception as exc: # pragma: no cover - logger.debug("conversation.item.truncate failed: %s", exc) + ) + except Exception as exc: # pragma: no cover + logger.debug("conversation.item.truncate failed: %s", exc) await self._ws.send(json.dumps({"type": "response.cancel"})) # Reset per-response tracking so subsequent audio chunks (post-cancel # late frames) and the next response.create start clean. @@ -691,6 +706,21 @@ async def send_text(self, text: str) -> None: ) await self._ws.send(json.dumps({"type": "response.create"})) + async def request_response(self) -> None: + """Trigger ``response.create`` with no new user item. + + Used by the Realtime stream-handler to drive a response after the + client-side hallucination filter accepts an + ``input_audio_transcription.completed`` event. The server VAD + config sets ``create_response: false`` so OpenAI no longer + auto-creates a response on every ``input_audio_buffer.committed``; + Patter is now responsible for triggering it explicitly when a + real user turn lands. + """ + if self._ws is None: + return + await self._ws.send(json.dumps({"type": "response.create"})) + async def send_first_message(self, text: str) -> None: """Make the AI speak ``text`` as its opening line. diff --git a/libraries/python/getpatter/providers/openai_realtime_2.py b/libraries/python/getpatter/providers/openai_realtime_2.py new file mode 100644 index 00000000..fb73ac50 --- /dev/null +++ b/libraries/python/getpatter/providers/openai_realtime_2.py @@ -0,0 +1,775 @@ +"""OpenAI Realtime adapter for the GA Realtime API (``gpt-realtime-2``). + +``gpt-realtime-2`` is served from the same ``wss://api.openai.com/v1/realtime`` +endpoint as the v1-beta family, but the GA endpoint: + +- REJECTS the legacy ``OpenAI-Beta: realtime=v1`` header. +- REQUIRES ``session.type == "realtime"`` at the root of ``session.update``. +- Uses ``output_modalities`` (was ``modalities``). +- Nests audio config under ``audio.{input,output}`` with MIME ``type`` + strings (``audio/pcm``) instead of the v1 enum strings (``g711_ulaw``, + ``pcm16``) and moves ``voice`` under ``audio.output.voice``, + ``transcription`` + ``turn_detection`` under ``audio.input``. + +Everything ELSE (event names, audio delta dispatch, barge-in / truncate +semantics, tool calling) is API-compatible with the v1 family — modulo a +small set of renamed events the GA API ships — so this adapter subclasses +:class:`OpenAIRealtimeAdapter` and overrides only :meth:`connect`, +:meth:`send_audio`, :meth:`send_first_message`, and the event-translation +layer. The runtime behaviour (``cancel_response``, ``send_text``, +``send_function_result``, ``close``) is inherited unchanged. + +Note on audio transport +----------------------- +The GA endpoint accepts only PCM-16-LE with rate >= 24000 for both +``session.audio.input.format`` and ``session.audio.output.format``. +The ``audio/pcmu`` MIME type is accepted at the protocol level but the +server's audio engine silently drops mulaw frames — ``input_audio_buffer.commit`` +returns "buffer only has 0.00ms of audio" and the call ends up muted. +Until OpenAI documents native g711_ulaw on the GA endpoint we transcode +on both directions on the Patter side: + +- Inbound (Twilio/Telnyx → model): mulaw 8 kHz → PCM 24 kHz +- Outbound (model → Twilio/Telnyx): PCM 24 kHz → mulaw 8 kHz + +The outbound path uses a stateful two-stage resampler (24k → 16k → 8k) +so phase carries across chunk boundaries and eliminates the click artefact +that a stateless helper would produce at every audio-delta boundary. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import struct +from typing import Any, AsyncGenerator + +import websockets + +from getpatter.audio.transcoding import ( + StatefulResampler, + mulaw_to_pcm16, + pcm16_to_mulaw, +) +from getpatter.providers.openai_realtime import ( + OpenAIRealtimeAdapter, + OpenAIRealtimeVADType, + OpenAITranscriptionModel, +) + +logger = logging.getLogger("getpatter.openai_realtime_2") + +__all__ = ["OpenAIRealtime2Adapter"] + +# --------------------------------------------------------------------------- +# GA event name translation +# --------------------------------------------------------------------------- +# Mapping from GA Realtime event names back to the v1 names the rest of +# Patter (StreamHandler, metrics, dashboard) listens for. The GA API +# renamed several events but kept payload shapes identical, so we can +# translate at the WebSocket boundary and reuse the v1 event handler +# untouched. +_GA_TO_V1_EVENT_NAMES: dict[str, str] = { + "response.output_audio.delta": "response.audio.delta", + "response.output_audio.done": "response.audio.done", + "response.output_audio_transcript.delta": "response.audio_transcript.delta", + "response.output_audio_transcript.done": "response.audio_transcript.done", +} + +# 20 ms of mulaw at 8 kHz = 160 bytes. Splitting large GA deltas into +# 160-byte frames gives the StreamHandler → bridge.send_audio chain the +# natural cadence it expects. +_MULAW_FRAME_BYTES = 160 + +# Gain boost applied to inbound telephony audio before upsampling to 24 kHz. +# The GA server VAD is calibrated against studio-quality 24 kHz audio; +# telephony-band mulaw typically sits at ~-12 dB peak relative to that. +# 2x gain lifts the signal into the VAD's expected range so speech_started +# fires reliably on phone-band input. +_INBOUND_GAIN = 2 + + +class OpenAIRealtime2Adapter(OpenAIRealtimeAdapter): + """Realtime WebSocket adapter speaking OpenAI's GA Realtime API. + + Subclasses :class:`OpenAIRealtimeAdapter` and overrides: + + - :meth:`connect` — omits ``OpenAI-Beta`` header; sends GA-shape + ``session.update`` with nested ``audio.{input,output}`` and + ``output_modalities``. + - :meth:`send_audio` — transcodes inbound mulaw 8 kHz → PCM 24 kHz + before appending to the input audio buffer. + - :meth:`receive_events` — translates GA event names back to v1 names + and decodes outbound PCM 24 kHz → mulaw 8 kHz in 20 ms slices. + - :meth:`send_first_message` — uses ``output_modalities`` and re-injects + ``audio.output.voice`` for the first response.create. + + Everything else (``cancel_response``, ``send_text``, + ``send_function_result``, ``close``) is inherited unchanged. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + # Stateful two-stage outbound resampler: 24k → 16k → 8k. + # Created lazily on the first audio delta so each session has its own state. + self._outbound_resampler_24to16: StatefulResampler | None = None + self._outbound_resampler_16to8: StatefulResampler | None = None + # Last 8 kHz input sample carried across chunk boundaries for the + # direct 3x linear upsample. The carry guarantees the first output of + # each chunk interpolates from the real preceding sample, not from a + # replicated first sample — without it every 20 ms Twilio frame + # boundary becomes a small DC step that the GA server VAD interprets + # as constant low-energy noise. + self._inbound_8k_carry: int | None = None + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _build_ga_session_config(self) -> dict[str, Any]: + """Build the GA-shape session.update body.""" + # The GA endpoint requires audio/pcm with rate >= 24000 for both + # directions. mulaw is not honoured by the audio engine even though + # the protocol accepts the MIME type. + fmt: dict[str, Any] = {"type": "audio/pcm", "rate": 24000} + config: dict[str, Any] = { + "type": "realtime", + "output_modalities": self.modalities or ["audio"], + "audio": { + "input": { + "format": fmt, + "transcription": { + "model": self.input_audio_transcription_model + or OpenAITranscriptionModel.WHISPER_1.value, + }, + # VAD threshold raised back to the OpenAI default (0.5) + # on 2026-05-22. The earlier 0.1 tuning (motivated by + # the upsampled telephony-band loss in high frequencies) + # made the server VAD trigger on the carrier-loopback + # echo of the agent's OWN outbound audio in PSTN no-AEC + # scenarios. Combined with the default + # ``turn_detection.create_response: true``, every phantom + # ``speech_started`` ended a turn early and auto-created + # a new response that the agent immediately spoke over, + # leading to a runaway loop where the first message was + # repeatedly cut and re-generated. + "turn_detection": { + "type": self.vad_type or OpenAIRealtimeVADType.SERVER_VAD.value, + "threshold": 0.5, + "prefix_padding_ms": 300, + "silence_duration_ms": self.silence_duration_ms, + # Defer ``response.create`` to the application: when + # OpenAI's server VAD commits an + # ``input_audio_buffer.committed`` segment that turns + # out to be a Whisper hallucination on silence/echo, + # auto-creating a response would generate a phantom + # turn (the model reads the hallucinated text as user + # input). Patter triggers ``response.create`` + # explicitly in the Realtime stream-handler AFTER + # validating ``transcript_input`` against the + # hallucination filter. Pair with + # ``interrupt_response: false`` so server VAD also + # leaves in-flight responses alone — barge-in is + # gated client-side. + "create_response": False, + "interrupt_response": False, + }, + }, + "output": { + "format": fmt, + "voice": self.voice, + }, + }, + "instructions": self.instructions + or f"You are a helpful voice assistant. Respond in {self.language}. Be concise and natural.", + } + if self.temperature is not None: + config["temperature"] = self.temperature + if self.max_response_output_tokens is not None: + config["max_output_tokens"] = self.max_response_output_tokens + if self.tool_choice is not None: + config["tool_choice"] = self.tool_choice + if self.reasoning_effort is not None: + config["reasoning"] = {"effort": self.reasoning_effort} + if self.tools: + config["tools"] = [self._build_tool_wire_format(t) for t in self.tools] + return config + + def _transcode_inbound_mulaw8_to_pcm24(self, mulaw: bytes) -> bytes: + """mulaw 8 kHz → PCM-16-LE 24 kHz via direct 3x linear interpolation. + + For every consecutive pair of 8 kHz samples (s_a, s_b) we emit three + 24 kHz samples:: + + out_0 = s_a + out_1 = round(2/3·s_a + 1/3·s_b) + out_2 = round(1/3·s_a + 2/3·s_b) + + A one-sample carry across chunk boundaries eliminates the DC step at + every 20 ms Twilio frame boundary that otherwise causes the GA server + VAD to read constant low-energy noise and never fire speech_started. + The first chunk (no carry yet) loses 3 output samples at the leading + edge (~375 µs), which is well below any audible artefact and well + below the VAD's 300 ms prefix-padding window. + + A 2x gain boost lifts telephony-band audio (~-12 dB peak) into the + range the GA VAD was calibrated against. Int16 values are clamped to + ±32767 to avoid wrap-around. + """ + pcm8 = mulaw_to_pcm16(mulaw) + num_samples = len(pcm8) // 2 + if num_samples == 0: + return b"" + + # Unpack all 8 kHz samples at once and apply gain. + samples8 = [ + max( + -32768, + min(32767, struct.unpack_from(" bytes: + """Base64 PCM-16-LE 24 kHz → mulaw 8 kHz. + + Uses a stateful two-stage resampler (24k → 16k → 8k) to eliminate + boundary clicks. The 16k→8k stage uses audioop's built-in anti-alias + filter which removes energy above 4 kHz before decimation, preventing + aliasing artefacts that a direct 3:1 decimator would produce on the + voice content emitted by gpt-realtime-2. + """ + if self._outbound_resampler_24to16 is None: + self._outbound_resampler_24to16 = StatefulResampler( + src_rate=24000, dst_rate=16000 + ) + self._outbound_resampler_16to8 = StatefulResampler( + src_rate=16000, dst_rate=8000 + ) + pcm24 = base64.b64decode(delta_b64) + pcm16 = self._outbound_resampler_24to16.process(pcm24) + pcm8 = self._outbound_resampler_16to8.process(pcm16) # type: ignore[union-attr] + if not pcm8: + return b"" + return pcm16_to_mulaw(pcm8) + + def _translate_ga_event(self, raw: str) -> list[str]: + """Translate a raw GA JSON frame to a list of v1-compatible JSON frames. + + For ``response.output_audio.delta`` frames the outbound PCM 24 kHz + audio is transcoded to mulaw 8 kHz and split into 20 ms slices + (160 bytes each), each yielded as a separate ``response.audio.delta`` + frame. All other GA-renamed events are rewritten to their v1 name. + Returns the unmodified raw string if no translation is needed. + """ + try: + data = json.loads(raw) + except Exception: + return [raw] + + event_type = data.get("type", "") + + if event_type == "response.output_audio.delta": + delta_b64: str = data.get("delta", "") + if not isinstance(delta_b64, str): + return [raw] + mulaw = self._transcode_outbound_pcm24_to_mulaw8(delta_b64) + if not mulaw: + return [] # resampler warmup — no output yet + frames: list[str] = [] + for off in range(0, len(mulaw), _MULAW_FRAME_BYTES): + chunk = mulaw[off : off + _MULAW_FRAME_BYTES] + frame = dict(data) + frame["type"] = "response.audio.delta" + frame["delta"] = base64.b64encode(chunk).decode("ascii") + frames.append(json.dumps(frame)) + return frames + + v1_name = _GA_TO_V1_EVENT_NAMES.get(event_type) + if v1_name is not None: + data["type"] = v1_name + return [json.dumps(data)] + + return [raw] + + # ------------------------------------------------------------------ + # Overridden public methods + # ------------------------------------------------------------------ + + async def connect(self) -> None: + """Connect to the GA Realtime endpoint and apply the GA session config. + + Differences from the v1 ``connect()``: + + - Header ``OpenAI-Beta: realtime=v1`` is OMITTED. + - ``session.update`` uses the GA shape: nested ``audio.{input,output}``, + ``output_modalities``, ``session.type == "realtime"``. + - Surfaces real GA-side rejection errors (``invalid_model``, + ``missing_required_parameter``) immediately instead of timing out. + """ + url = f"{self.OPENAI_REALTIME_URL}?model={self.model}" + self._ws = await websockets.connect( + url, + additional_headers={ + "Authorization": f"Bearer {self.api_key}", + }, + ping_interval=20, + ping_timeout=20, + ) + self._running = True + + try: + # Wait for session.created. + raw = await asyncio.wait_for(self._ws.recv(), timeout=15.0) + data = json.loads(raw) + if data.get("type") == "error": + err = data.get("error") or {} + msg = err.get("message") or err.get("code") or str(data) + raise RuntimeError(f"OpenAI Realtime 2 setup error: {msg}") + if data.get("type") != "session.created": + err = data.get("error") or {} + msg = err.get("message") or err.get("code") or str(data) + raise RuntimeError( + f"Expected session.created, got {data.get('type')!r}: {msg}" + ) + + await self._ws.send( + json.dumps( + { + "type": "session.update", + "session": self._build_ga_session_config(), + } + ) + ) + + # Wait for session.updated, surface any error immediately. + await self._await_session_updated_ga() + + except Exception: + await self._ws.close() + self._ws = None + self._running = False + raise + + async def _await_session_updated_ga(self) -> None: + """Wait for ``session.updated``, raising on ``error`` events.""" + deadline = asyncio.get_event_loop().time() + self._SESSION_UPDATE_TIMEOUT + while True: + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + logger.warning( + "OpenAI Realtime 2: no session.updated received after %.1fs; " + "continuing anyway", + self._SESSION_UPDATE_TIMEOUT, + ) + return + try: + raw = await asyncio.wait_for(self._ws.recv(), timeout=remaining) + except TimeoutError: + logger.warning( + "OpenAI Realtime 2: no session.updated received after %.1fs; " + "continuing anyway", + self._SESSION_UPDATE_TIMEOUT, + ) + return + try: + data = json.loads(raw) + except Exception: + continue + if data.get("type") == "session.updated": + return + if data.get("type") == "error": + err = data.get("error") or {} + msg = err.get("message") or err.get("code") or str(data) + raise RuntimeError(f"OpenAI Realtime 2 setup error: {msg}") + # Any other event gets buffered for the normal receive loop. + self._pending_events.append(raw) + + async def open_parked_connection(self): # type: ignore[no-untyped-def] + """Open a fresh GA Realtime WS during ringing, prime + ``session.update`` / ``session.updated``, and return the OPEN + socket WITHOUT taking it on ``self._ws``. + + Used by the prewarm pipeline to park a Realtime connection + during the carrier ringing window so the per-call StreamHandler + can adopt a fully-primed session at carrier ``start`` — + eliminating the TCP + TLS + HTTP-101 + ``session.update`` ack + round-trip from the critical path. Saves ~300-600 ms of + first-audible-word latency on outbound. + + Bounded by 8 s (matches the legacy v1 adapter). Raises on + timeout / handshake failure / GA-side rejection — the prewarm + pipeline treats any error as a cache miss and the call falls + through to the cold :meth:`connect` path. + + Billing safety: confirmed by OpenAI's Managing Realtime Costs + guide — ``session.update`` does NOT invoke the model and bills + no tokens. An idle parked socket costs $0. Call-completion / + no-answer paths drain the slot via ``_close_parked_slot``. + + Override of the legacy v1 adapter's parker so the GA shape + (``session.type == "realtime"``, ``output_modalities``, nested + ``audio.{input,output}``) is sent instead of the v1-beta flat + shape. + """ + url = f"{self.OPENAI_REALTIME_URL}?model={self.model}" + # Aggressive ping cadence on the parked socket. OpenAI's GA + # Realtime endpoint closes idle sockets within ~5-7 s when no + # frames are seen — the protocol-level WS PING is enough to + # keep the session alive between park (~T0) and adopt (whenever + # the callee picks up, typically T+3-15 s on cellular). 4 s + # cadence guarantees at least one ping reaches the server + # before the idle-disconnect window fires. The live session + # keeps the parent's 20 / 20 default — once the call is live, + # bidirectional audio frames are themselves the keepalive. + ws = await asyncio.wait_for( + websockets.connect( + url, + additional_headers={ + "Authorization": f"Bearer {self.api_key}", + }, + ping_interval=4, + ping_timeout=4, + ), + timeout=8.0, + ) + try: + raw = await asyncio.wait_for(ws.recv(), timeout=2.0) + data = json.loads(raw) + if data.get("type") != "session.created": + err = data.get("error") or {} + msg = err.get("message") or err.get("code") or str(data) + raise RuntimeError( + f"Expected session.created on parked GA WS, " + f"got {data.get('type')!r}: {msg}" + ) + await ws.send( + json.dumps( + { + "type": "session.update", + "session": self._build_ga_session_config(), + } + ) + ) + # Drain frames until session.updated (or 1.5 s timeout). + deadline = asyncio.get_event_loop().time() + 1.5 + while True: + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + break + try: + raw = await asyncio.wait_for(ws.recv(), timeout=remaining) + except Exception: + break + try: + data = json.loads(raw) + except Exception: + continue + if isinstance(data, dict) and data.get("type") == "session.updated": + break + if isinstance(data, dict) and data.get("type") == "error": + err = data.get("error") or {} + msg = err.get("message") or err.get("code") or str(data) + raise RuntimeError(f"OpenAI Realtime 2 parked-setup error: {msg}") + except Exception: + try: + await ws.close() + except Exception: + pass + raise + # Application-level keepalive. Empirically, OpenAI's GA Realtime + # edge closes idle parked sockets within ~6-7 s even with a 4 s + # WS-level ping — protocol PINGs alone are not counted as + # activity. Sending an idempotent ``session.update`` every 3 s + # is the documented "ping" pattern (re-affirms session config, + # bills no tokens) and reliably keeps the socket alive across + # the 3-15 s ringing window. Task also drains incoming acks so + # the receive buffer doesn't back-pressure the writer. Cancelled + # by :meth:`adopt_websocket` when the live adapter takes over. + keepalive_task = asyncio.create_task( + self._parked_keepalive_loop(ws), + name=f"openai-realtime-parked-keepalive:{id(ws)}", + ) + attached = False + try: + ws._parked_keepalive_task = keepalive_task # type: ignore[attr-defined] + attached = True + except Exception as exc: + keepalive_task.cancel() + logger.info("[PREWARM-KA] setattr failed: %s — task cancelled", exc) + logger.info( + "[PREWARM-KA] task scheduled attached=%s ws_id=%s", attached, id(ws) + ) + return ws + + async def _parked_keepalive_loop(self, ws) -> None: # type: ignore[no-untyped-def] + """Drain incoming frames and emit a no-op ``session.update`` + every 3 s on the parked GA Realtime WS until cancelled.""" + logger.info("[PREWARM-KA] loop started ws_id=%s", id(ws)) + next_ping = asyncio.get_event_loop().time() + 3.0 + pings_sent = 0 + try: + while True: + now = asyncio.get_event_loop().time() + wait_for = max(0.0, next_ping - now) + try: + await asyncio.wait_for(ws.recv(), timeout=wait_for) + continue + except asyncio.TimeoutError: + pass + except Exception as exc: + logger.info( + "[PREWARM-KA] recv died after %d pings: %s", + pings_sent, + exc, + ) + return + try: + await ws.send( + json.dumps( + { + "type": "session.update", + "session": self._build_ga_session_config(), + } + ) + ) + pings_sent += 1 + logger.info( + "[PREWARM-KA] sent session.update #%d ws_closed=%s", + pings_sent, + getattr(ws, "closed", "?"), + ) + except Exception as exc: + logger.info( + "[PREWARM-KA] send failed after %d pings: %s", + pings_sent, + exc, + ) + return + next_ping = asyncio.get_event_loop().time() + 3.0 + except asyncio.CancelledError: + logger.info( + "[PREWARM-KA] cancelled after %d pings (adopted or closed)", + pings_sent, + ) + raise + + def adopt_websocket(self, ws) -> None: # type: ignore[no-untyped-def] + """Adopt a pre-opened, already-``session.updated`` GA Realtime WS + produced by the prewarm pipeline. Skips the cold-connect path — + saves ~300-600 ms on first audible word. + + Caller MUST verify the WS is still alive before calling and + MUST have already received ``session.updated`` on the parked + socket. If the parked WS died between park and adopt, fall back + to :meth:`connect`. Parity with parent ``adopt_websocket`` but + explicit here so the override surfaces in the GA adapter's + public API (and so tooling that introspects the adapter sees + the method without walking the MRO). + """ + # Cancel the parked keepalive loop — the live receive_events() + # owns the WS from here on. Awaiting cancellation isn't possible + # in a sync method; the loop tolerates abrupt cancellation + # (it raises CancelledError out of its single recv()/send()). + ka = getattr(ws, "_parked_keepalive_task", None) + if ka is not None: + try: + ka.cancel() + except Exception: + pass + try: + delattr(ws, "_parked_keepalive_task") + except Exception: + pass + self._ws = ws + self._running = True + + async def send_audio(self, audio: bytes) -> None: + """Send audio to the GA Realtime API. + + Transcodes inbound mulaw 8 kHz (from Twilio/Telnyx) to PCM-16-LE + 24 kHz before appending to the input audio buffer. The GA server's + audio engine ignores mulaw frames even though it accepts ``audio/pcmu`` + at the protocol level — raw mulaw results in "buffer only has 0.00ms + of audio" and a muted call. + """ + if self._ws is None: + return + pcm24 = self._transcode_inbound_mulaw8_to_pcm24(audio) + if not pcm24: + return + encoded = base64.b64encode(pcm24).decode("ascii") + await self._ws.send( + json.dumps({"type": "input_audio_buffer.append", "audio": encoded}) + ) + + async def receive_events(self) -> AsyncGenerator[tuple[str, Any], None]: + """Yield events from the GA Realtime API, translating event names to v1. + + Outbound audio deltas (``response.output_audio.delta``) are: + 1. Transcoded from PCM 24 kHz → mulaw 8 kHz. + 2. Split into 20 ms / 160-byte slices and emitted as individual + ``("audio", bytes)`` events so StreamHandler's cadence is + preserved. + + Other GA-renamed events are translated to their v1 equivalents + before dispatch. + """ + if self._ws is None: + return + + import websockets.exceptions as _ws_exc + + async def _iter_raw(): + while self._pending_events: + yield self._pending_events.popleft() + async for msg in self._ws: + yield msg + + try: + async for raw in _iter_raw(): + # Translate GA event names / audio format. + translated_frames = self._translate_ga_event(raw) + for frame in translated_frames: + # Delegate actual event parsing to a helper so we don't + # duplicate the full dispatch table from the parent class. + async for event in self._dispatch_frame(frame): + yield event + except _ws_exc.ConnectionClosed as exc: + if self._running and getattr(exc, "code", 1000) != 1000: + yield ( + "error", + { + "type": "connection_closed", + "code": getattr(exc, "code", None), + "reason": getattr(exc, "reason", ""), + }, + ) + finally: + self._running = False + + async def _dispatch_frame(self, raw: str) -> AsyncGenerator[tuple[str, Any], None]: + """Parse and dispatch a single (already translated) JSON frame. + + This is a subset of the parent's ``receive_events`` dispatch table, + re-used after GA→v1 name translation so we don't duplicate logic. + """ + import time as _time + + try: + data = json.loads(raw) + except Exception: + return + event_type = data.get("type", "") + + if event_type == "response.audio.delta": + audio_bytes = base64.b64decode(data.get("delta", "")) + # For GA path the audio is already mulaw 8 kHz (transcoded in + # _translate_ga_event). Use the mulaw estimator (8 bytes/ms). + self._current_response_audio_ms += len(audio_bytes) // 8 + if self._current_response_first_audio_at is None: + self._current_response_first_audio_at = _time.monotonic() + yield ("audio", audio_bytes) + + elif event_type == "response.audio_transcript.delta": + yield ("transcript_output", data.get("delta", "")) + + elif event_type in ( + "response.content_part.added", + "response.output_item.added", + ): + item = data.get("item") or {} + item_id = item.get("id") or data.get("item_id") + if item_id: + self._current_response_item_id = item_id + self._current_response_audio_ms = 0 + self._current_response_first_audio_at = None + + elif event_type == "input_audio_buffer.speech_started": + yield ("speech_started", None) + + elif event_type == "input_audio_buffer.speech_stopped": + yield ("speech_stopped", None) + + elif event_type == "conversation.item.input_audio_transcription.completed": + yield ("transcript_input", data.get("transcript", "")) + + elif event_type == "response.function_call_arguments.done": + yield ( + "function_call", + { + "call_id": data.get("call_id", ""), + "name": data.get("name", ""), + "arguments": data.get("arguments", "{}"), + }, + ) + + elif event_type == "response.done": + self._current_response_item_id = None + self._current_response_audio_ms = 0 + self._current_response_first_audio_at = None + yield ("response_done", data.get("response", {})) + + elif event_type == "error": + err = data.get("error", {}) + logger.error("OpenAI Realtime 2 error: %s", err) + yield ("error", err) + + async def send_first_message(self, text: str) -> None: + """Make the AI speak ``text`` as its opening line using GA-shape fields. + + Two differences from the v1 path: + + 1. Uses ``output_modalities`` (the GA endpoint rejects + ``response.modalities``). + 2. Re-injects ``audio.output.voice`` — the GA ``response.create`` + does NOT inherit voice from the session for this explicit request; + it falls back to the server-side default (``marin``, female) when + the field is omitted. + """ + if self._ws is None: + return + response_body: dict[str, Any] = { + "output_modalities": ["audio"], + "audio": {"output": {"voice": self.voice}}, + "instructions": ( + f"Say exactly the following sentence as your first turn " + f'and nothing else: "{text}"' + ), + } + # ``reasoning.effort`` is only accepted by the flagship GA + # variants (``gpt-realtime``, ``gpt-realtime-2``, …) — the + # cost-tier ``gpt-realtime-mini`` rejects it as "Unsupported + # option for this model". Forward the field only when the + # caller explicitly configured it. + if self.reasoning_effort is not None: + response_body["reasoning"] = {"effort": self.reasoning_effort} + await self._ws.send( + json.dumps({"type": "response.create", "response": response_body}) + ) diff --git a/libraries/python/getpatter/providers/silero_vad.py b/libraries/python/getpatter/providers/silero_vad.py index a0b813bf..9be17df8 100644 --- a/libraries/python/getpatter/providers/silero_vad.py +++ b/libraries/python/getpatter/providers/silero_vad.py @@ -212,6 +212,20 @@ def for_phone_call(cls, **overrides) -> "SileroVAD": """ defaults: dict = { "sample_rate": SileroSampleRate.HZ_16000, + # Telephony bumps the activation threshold from the upstream + # 0.5 → 0.8 (with deactivation 0.65) so background voices + # and low-volume audio in the caller's room don't trip + # barge-in. Near-mic speech typically scores 0.85-0.98 on + # Silero — above 0.8 — while a distant second speaker + # through a phone's noise-suppression pipeline lands around + # 0.4-0.6 and is now correctly ignored. Bumped twice during + # 2026-05-20 acceptance: first 0.5 → 0.7 (still triggered on + # quiet voices), then 0.7 → 0.8. Trade-off: a whispered + # legitimate input may not trigger; typical phone-call + # speakers are unaffected. Pass an explicit + # ``activation_threshold`` to override per call site. + "activation_threshold": 0.8, + "deactivation_threshold": 0.65, } defaults.update(overrides) return cls.load(**defaults) diff --git a/libraries/python/getpatter/providers/twilio_adapter.py b/libraries/python/getpatter/providers/twilio_adapter.py index 0ed71026..f8441ce3 100644 --- a/libraries/python/getpatter/providers/twilio_adapter.py +++ b/libraries/python/getpatter/providers/twilio_adapter.py @@ -6,6 +6,7 @@ import asyncio import logging +import re from functools import partial from twilio.rest import Client as TwilioClient from twilio.twiml.voice_response import VoiceResponse, Connect @@ -14,6 +15,22 @@ logger = logging.getLogger("getpatter.providers.twilio_adapter") +_PASCAL_TO_SNAKE_RE = re.compile(r"(? str: + """Translate a PascalCase / camelCase Twilio param to snake_case. + + The ``twilio-python`` SDK's ``client.calls.create(**kwargs)`` accepts + snake_case keyword arguments only — it translates them to the + PascalCase form Twilio's REST wire protocol requires. Passing a + PascalCase key directly raises ``TypeError: unexpected keyword + argument``. This helper normalises both shapes so the adapter is + robust regardless of how the caller spelled the param. + """ + return _PASCAL_TO_SNAKE_RE.sub("_", name).lower() + + class TwilioAdapter(TelephonyProvider): """:class:`TelephonyProvider` implementation backed by the Twilio REST API.""" @@ -68,7 +85,14 @@ async def initiate_call( twiml.append(connect) call_kwargs: dict = {"to": to_number, "from_": from_number, "twiml": str(twiml)} if extra_params: - call_kwargs.update(extra_params) + # Defensive normalisation: the ``twilio-python`` SDK rejects + # PascalCase kwargs (``StatusCallback``, ``MachineDetection``, + # …) with ``TypeError: unexpected keyword argument``. + # ``getpatter.client`` already builds the dict in snake_case + # form; this guard catches any third-party caller (or future + # regression) that still passes the wire-protocol spelling. + for key, value in extra_params.items(): + call_kwargs[_to_snake_case(key)] = value call = await self._run_sync(self._twilio_client.calls.create, **call_kwargs) return call.sid @@ -98,10 +122,26 @@ def record_call_end_cost(self, *, duration_seconds: float, direction: str) -> No logger.debug("record_call_end_cost failed", exc_info=True) @staticmethod - def generate_stream_twiml(stream_url: str) -> str: - """Return TwiML that connects the inbound call to the given media stream URL.""" + def generate_stream_twiml( + stream_url: str, + parameters: dict[str, str] | None = None, + ) -> str: + """Return TwiML that connects the inbound call to the media stream URL. + + ``parameters`` is forwarded as ```` + children of ````. Twilio Media Streams ignores query-string + params on the ```` (they are stripped before the WS + handshake), so ```` tags are the supported way to + pre-populate ``start.customParameters`` on the WS payload. Used by + the inbound path to carry caller / callee through to the bridge. + """ response = VoiceResponse() connect = Connect() - connect.stream(url=stream_url) + stream = connect.stream(url=stream_url) + if parameters: + for name, value in parameters.items(): + if value is None: + continue + stream.parameter(name=name, value=str(value)) response.append(connect) return str(response) diff --git a/libraries/python/getpatter/server.py b/libraries/python/getpatter/server.py index e02ac74c..7b23b723 100644 --- a/libraries/python/getpatter/server.py +++ b/libraries/python/getpatter/server.py @@ -349,6 +349,11 @@ async def _on_call_start(data): call_id_str, caller=resolved_caller, callee=resolved_callee, + direction=( + data.get("direction") + or active_record.get("direction") + or "inbound" + ), telephony_provider=data.get("telephony_provider", "") or "", provider_mode=getattr(agent, "provider", "") or "", agent=_agent_snapshot(), diff --git a/libraries/python/getpatter/services/call_log.py b/libraries/python/getpatter/services/call_log.py index 26b283a6..0c467364 100644 --- a/libraries/python/getpatter/services/call_log.py +++ b/libraries/python/getpatter/services/call_log.py @@ -104,10 +104,18 @@ def _retention_days() -> int: def _redact_mode() -> str: - raw = (os.environ.get("PATTER_LOG_REDACT_PHONE") or "mask").strip().lower() + # Default ``full`` (changed from ``mask`` on 2026-05-21): the dashboard + # UI's reveal toggle (``revealed=true`` in ``format.ts:fmtPhone``) + # cannot reconstruct a raw number once the persisted record has + # already been masked, so storing raw on disk is required for the + # toggle to actually work. The on-disk path + # (``~/Library/Application Support/patter/`` on macOS / XDG data dir + # on Linux) is user-private. Override with ``PATTER_LOG_REDACT_PHONE=mask`` + # for setups that ship logs off-host. + raw = (os.environ.get("PATTER_LOG_REDACT_PHONE") or "full").strip().lower() if raw in {"full", "mask", "hash_only"}: return raw - return "mask" + return "full" def _redact_phone(raw: str) -> str: @@ -211,6 +219,7 @@ def log_call_start( *, caller: str = "", callee: str = "", + direction: str = "", telephony_provider: str = "", provider_mode: str = "", agent: dict[str, Any] | None = None, @@ -233,6 +242,7 @@ def log_call_start( "status": "in_progress", "caller": _redact_phone(caller), "callee": _redact_phone(callee), + "direction": direction or "inbound", "telephony_provider": telephony_provider, "provider_mode": provider_mode, "agent": agent or {}, diff --git a/libraries/python/getpatter/services/metrics.py b/libraries/python/getpatter/services/metrics.py index 1637816b..4316f0e8 100644 --- a/libraries/python/getpatter/services/metrics.py +++ b/libraries/python/getpatter/services/metrics.py @@ -90,6 +90,18 @@ def __init__( self._bargein_stopped_at: float | None = None self._turn_user_text: str = "" self._turn_stt_audio_seconds: float = 0.0 + # Guard against the record_turn_interrupted / record_turn_complete + # race. A VAD-path barge-in fires ``record_turn_interrupted`` + # synchronously inside the handler while the in-flight pipeline + # LLM stream keeps unwinding on its own task. When the LLM stream + # eventually exits, the pipeline path falls through to + # ``record_turn_complete``, which would push a second turn for + # the same logical exchange (this time carrying ``user_text=""`` + # because the field was already reset). The flag is flipped by + # ``record_turn_interrupted`` and read by ``record_turn_complete`` + # so the late ``record_turn_complete`` becomes a no-op until the + # next ``start_turn`` re-arms the accumulator. + self._turn_already_closed: bool = False # Cross-turn TTFT storage so _emit_turn_metrics can read it after reset self._last_turn_llm_ttft_ms: float = 0.0 @@ -201,6 +213,7 @@ def start_turn(self) -> None: self._bargein_stopped_at = None self._turn_user_text = "" self._turn_stt_audio_seconds = 0.0 + self._turn_already_closed = False # Reset per-turn TTFB guard flags self._llm_ttfb_emitted = False self._tts_ttfb_emitted = False @@ -397,8 +410,18 @@ def record_tts_stopped(self, ts: float | None = None) -> None: """ self._bargein_stopped_at = ts if ts is not None else time.monotonic() - def record_turn_complete(self, agent_text: str) -> TurnMetrics: - """Finalize the current turn and return its metrics.""" + def record_turn_complete(self, agent_text: str) -> TurnMetrics | None: + """Finalize the current turn and return its metrics. + + Returns ``None`` when ``record_turn_interrupted`` has already + closed the current turn — this protects against the VAD-barge-in + / pipeline-LLM race where both paths try to finalise the same + logical turn and the second would otherwise push a phantom entry + with ``user_text=''``. The caller treats ``None`` as "nothing to + emit"; ``_emit_turn_metrics`` is already null-safe. + """ + if self._turn_already_closed: + return None latency = self._compute_turn_latency() turn = TurnMetrics( turn_index=len(self._turns), @@ -420,16 +443,30 @@ def record_turn_complete(self, agent_text: str) -> TurnMetrics: {"call_id": self.call_id, "turn": turn}, ) self._reset_turn_state() + # Bidirectional guard: mark the turn as closed so a late + # record_turn_interrupted (e.g. from a future refactor that + # reorders the bargein + LLM-unwind paths) becomes a no-op + # instead of overwriting the just-emitted turn record. Mirrors + # the inverse guard in ``record_turn_interrupted`` and keeps + # the two close paths symmetric. + self._turn_already_closed = True return turn def record_turn_interrupted(self) -> TurnMetrics | None: """Handle a barge-in / interrupted turn. Returns partial ``TurnMetrics`` if a turn was in progress, else - ``None``. + ``None``. Also returns ``None`` when ``record_turn_complete`` has + already finalised the current turn — bidirectional parity with + the guard in :meth:`record_turn_complete`. Prevents an out-of- + order interruption (e.g. a future refactor that reorders the + bargein + LLM-unwind paths) from overwriting a turn that the + complete path already emitted. """ if self._turn_start is None: return None + if self._turn_already_closed: + return None latency = self._compute_turn_latency() turn = TurnMetrics( @@ -455,6 +492,10 @@ def record_turn_interrupted(self) -> TurnMetrics | None: {"call_id": self.call_id, "turn": turn}, ) self._reset_turn_state() + # Mark the turn as closed so a late record_turn_complete from + # the pipeline-LLM unwind path becomes a no-op (see + # _turn_already_closed). + self._turn_already_closed = True # Extra paranoia: explicitly null out anchors that have caused leaks # into subsequent turns when a barge-in is in flight. _reset_turn_state # already clears them, but keep this belt-and-braces line so future @@ -723,6 +764,13 @@ def _reset_turn_state(self) -> None: self._tts_first_byte = None self._tts_last_byte = None self._endpoint_signal_at = None + # Parity with TS ``metrics.ts:_resetTurnState`` — without clearing + # ``_turn_committed_mono`` here, the ``anchor_user_speech_start`` + # guard (``if self._turn_committed_mono is not None: return``) + # falsely no-ops on a VAD ``speech_start`` arriving between + # ``record_turn_complete`` and the next ``start_turn`` on + # back-to-back turns. + self._turn_committed_mono = None self._bargein_detected_at = None self._bargein_stopped_at = None self._turn_user_text = "" diff --git a/libraries/python/getpatter/stream_handler.py b/libraries/python/getpatter/stream_handler.py index a75c42dc..7f87983b 100644 --- a/libraries/python/getpatter/stream_handler.py +++ b/libraries/python/getpatter/stream_handler.py @@ -47,13 +47,42 @@ logger = logging.getLogger("getpatter") +def _is_parked_ws_alive(ws: object) -> bool: + """Best-effort liveness check across ``websockets`` library versions. + + The legacy client (``websockets<11``) exposes ``ws.closed: bool``. + The current asyncio client (``websockets>=12``) exposes ``ws.state`` + (an ``IntEnum`` with ``OPEN == 1``) and ``ws.close_code`` (``None`` + while still open). Return ``True`` only when we can confirm the + socket is OPEN — never default to True on unknown shapes, otherwise + we'd hand a dead socket to the live adapter. + """ + state = getattr(ws, "state", None) + if state is not None: + try: + return int(state) == 1 + except Exception: + return getattr(state, "name", "").upper() == "OPEN" + close_code = getattr(ws, "close_code", "__unset__") + if close_code != "__unset__": + return close_code is None + closed = getattr(ws, "closed", None) + if closed is None: + return False + return not bool(closed) + + # Minimum wall-clock duration (seconds) the agent must have been speaking # before barge-in is allowed to fire. AEC variant (1.0 s) covers the -# filter convergence window. NO_AEC variant (0.25 s) is anti-flicker -# only — used on PSTN where AEC is a no-op so there is no warmup to -# protect, and a long gate just suppresses real-user barge-in. +# filter convergence window. NO_AEC variant raised 0.1 → 0.5 s on +# 2026-05-19 after the 0.6.2 acceptance run showed a phantom VAD +# speech_start firing on the very first inbound frame, cancelling the +# prewarmed firstMessage and leaving the turn-state machine wedged +# (``_turn_already_closed=True``). 0.5 s filters those phantoms while +# still allowing real interruptions to land within half a second of +# agent onset. MIN_AGENT_SPEAKING_S_BEFORE_BARGE_IN_AEC = 1.0 -MIN_AGENT_SPEAKING_S_BEFORE_BARGE_IN_NO_AEC = 0.1 +MIN_AGENT_SPEAKING_S_BEFORE_BARGE_IN_NO_AEC = 0.5 # Backwards-compat alias used by tests; matches AEC variant. MIN_AGENT_SPEAKING_S_BEFORE_BARGE_IN = MIN_AGENT_SPEAKING_S_BEFORE_BARGE_IN_AEC @@ -66,6 +95,14 @@ # emit when fed silence or TTS echo on mulaw 8 kHz. Dropping them as turns # prevents the caller from entering a feedback loop where every silent frame # triggers a new LLM+TTS turn. Parity with TS ``HALLUCINATIONS``. +# +# Whisper-specific full-phrase hallucinations (the model's training set +# was dominated by YouTube captions — on silence / echo it falls back to +# the most common training-set closers). These fire HARD on PSTN echo +# loopback when the agent's outbound audio bleeds into the input buffer +# and the upstream VAD commits a "non-empty" segment to transcription. +# Comparison happens against the lower-cased + stripped form, so add +# the canonical lowercase spelling here. _STT_HALLUCINATIONS: frozenset[str] = frozenset( { "you", @@ -84,6 +121,22 @@ "bye", "right", "cool", + # Whisper YouTube-caption hallucinations + "thank you for watching", + "thanks for watching", + "thank you for watching!", + "thanks for watching!", + "thank you so much for watching", + "thanks for listening", + "please subscribe", + "subscribe", + "music", + "[music]", + "♪", + "[no audio]", + "[silence]", + "[blank_audio]", + "(silence)", } ) @@ -251,7 +304,7 @@ def create_metrics_accumulator( tts_model = str(getattr(agent.tts, "model", "") or "") else: tts_name = "elevenlabs" if elevenlabs_key else "" - elif provider == "openai_realtime": + elif provider in ("openai_realtime", "openai_realtime_2"): stt_name = "openai" tts_name = "openai" # Realtime collapses STT+LLM+TTS into one model — capture it so the @@ -262,7 +315,7 @@ def create_metrics_accumulator( elif provider == "elevenlabs_convai": stt_name = "elevenlabs" tts_name = "elevenlabs" - if provider == "openai_realtime": + if provider in ("openai_realtime", "openai_realtime_2"): llm_name = "openai" elif provider == "elevenlabs_convai": llm_name = "elevenlabs" @@ -744,6 +797,7 @@ def __init__( audio_format: str = "pcm16", input_transcode: str | None = None, speech_events=None, + pop_prewarmed_connections=None, ) -> None: super().__init__( agent=agent, @@ -763,6 +817,11 @@ def __init__( self._transfer_fn = transfer_fn self._hangup_fn = hangup_fn self._audio_format = audio_format + # Callback supplied by the telephony adapter so we can adopt a + # Realtime WS that ``Patter._park_provider_connections`` opened + # during the ringing window. ``None`` skips adoption — we fall + # back to a cold ``connect()``. + self._pop_prewarmed_connections = pop_prewarmed_connections # OpenAI Realtime API uses a single codec for both input and output # (``audio_format`` becomes both ``input_audio_format`` and # ``output_audio_format`` in the session). When the telephony leg @@ -902,10 +961,20 @@ async def _emit_tool_event( async def start(self) -> None: """Connect to OpenAI Realtime, register tools, and begin event forwarding.""" - from getpatter.providers.openai_realtime import ( - OpenAIRealtimeAdapter, # type: ignore[import] + # Both ``openai_realtime`` and ``openai_realtime_2`` engines now + # route through the GA-compatible ``OpenAIRealtime2Adapter`` — + # OpenAI deprecated the Beta Realtime API on 2026-05, returning + # `invalid_model` to the legacy ``session.update`` shape and the + # ``OpenAI-Beta: realtime=v1`` header. Only the default model + # string differs between the two engines (mini vs flagship); + # everything else (session shape, MIME types, event names) is + # identical and lives in the GA adapter. + from getpatter.providers.openai_realtime_2 import ( # type: ignore[import] + OpenAIRealtime2Adapter, ) + _adapter_cls = OpenAIRealtime2Adapter + # Resolve MCP servers BEFORE the adapter is built so the # discovered tools are visible in the first ``session.update``. # Failures are logged but not fatal — a dead MCP server should @@ -947,9 +1016,90 @@ async def start(self) -> None: ) if transcription_model is not None: adapter_kwargs["input_audio_transcription_model"] = transcription_model - self._adapter = OpenAIRealtimeAdapter(**adapter_kwargs) - await self._adapter.connect() - logger.debug("OpenAI Realtime connected") + self._adapter = _adapter_cls(**adapter_kwargs) + + # Try to adopt a Realtime WebSocket parked during the ringing + # window. When present we skip the cold ``connect()`` — the + # parked socket has already paid the TCP + TLS + HTTP-101 + + # ``session.update`` ack round-trip (~300-600 ms saved on first + # audible word). Fall back transparently on cache miss / dead + # socket / adapter missing ``adopt_websocket``. + parked: dict | None = None + pop_cb = self._pop_prewarmed_connections + if pop_cb is None: + logger.info( + "[PREWARM] callId=%s provider=openai_realtime SKIPPED adoption: " + "pop_prewarmed_connections callback not wired", + self.call_id, + ) + else: + try: + parked = pop_cb(self.call_id) + except Exception as exc: # noqa: BLE001 - best-effort + logger.info( + "[PREWARM] callId=%s provider=openai_realtime FAILED pop: %s", + self.call_id, + exc, + ) + parked = None + if parked is None: + logger.info( + "[PREWARM] callId=%s provider=openai_realtime no slot present " + "(cache miss / parked task still in flight)", + self.call_id, + ) + parked_realtime_ws = (parked or {}).get("openai_realtime") + adopt_ok = False + if parked_realtime_ws is not None: + adopt = getattr(self._adapter, "adopt_websocket", None) + # Liveness check robust across ``websockets`` versions. The + # legacy client exposes a ``closed`` bool, the new asyncio + # client exposes ``state`` (websockets.protocol.State enum) + # and ``close_code`` (None while OPEN). Pre-2025-04 we used + # ``getattr(ws, "closed", True)`` which defaulted to True + # when the attribute didn't exist — causing the GA-shape + # parked WS to be treated as dead and forcibly closed + # right before adoption. + ws_alive = _is_parked_ws_alive(parked_realtime_ws) + ws_closed = not ws_alive + if not callable(adopt): + logger.info( + "[PREWARM] callId=%s provider=openai_realtime adopter missing " + "adopt_websocket method", + self.call_id, + ) + elif not ws_alive: + logger.info( + "[PREWARM] callId=%s provider=openai_realtime parked WS died " + "between park and adopt (closed=%s)", + self.call_id, + ws_closed, + ) + else: + try: + adopt(parked_realtime_ws) + logger.info( + "[CONNECT] callId=%s provider=openai_realtime source=adopted ms=0", + self.call_id, + ) + adopt_ok = True + except Exception as exc: # noqa: BLE001 + logger.info( + "[PREWARM] callId=%s provider=openai_realtime adopt FAILED: %s", + self.call_id, + exc, + ) + if not adopt_ok: + try: + await parked_realtime_ws.close() + except Exception: + pass + if not adopt_ok: + await self._adapter.connect() + logger.debug( + "OpenAI Realtime connected (adapter=%s)", + getattr(_adapter_cls, "__name__", repr(_adapter_cls)), + ) if self.agent.first_message: # Start measuring latency for the firstMessage turn (sendText → @@ -1020,6 +1170,24 @@ async def _forward_events(self) -> None: elif ev_type == "transcript_input": logger.debug("User: %s", sanitize_log_value(ev_data)) + # Filter known Whisper-on-silence hallucinations. The + # Realtime API's input_audio_transcription is Whisper, + # and Whisper's training-set bias means PSTN echo / + # silence segments often transcribe as + # "Thank you for watching." / "Thanks for watching." / + # "[music]" etc. — feeding those back to the LLM + # produces phantom user turns the caller never spoke. + _ev_stripped = ( + (ev_data or "").strip().rstrip(".,!?;: ").strip().lower() + ) + if _ev_stripped in _STT_HALLUCINATIONS or not _ev_stripped: + logger.info( + "Realtime transcript_input dropped (likely " + "Whisper hallucination on silence/echo): %r", + sanitize_log_value((ev_data or "")[:60]), + ) + self._user_transcript_pending = False + continue if self.metrics is not None: # Fallback: start turn here if speech_stopped was missed # (server VAD disabled or custom config). @@ -1048,6 +1216,20 @@ async def _forward_events(self) -> None: "history": list(self.conversation_history), } ) + # Drive the assistant response. The session config sets + # ``turn_detection.create_response: false`` so OpenAI's + # server VAD no longer auto-creates a response on every + # ``input_audio_buffer.committed`` — that path triggers + # phantom assistant turns on Whisper-hallucinated input + # ("Thank you for watching." etc.). Patter now requests + # the response explicitly here, AFTER the + # hallucination filter accepts the transcript above. + request_response = getattr(self._adapter, "request_response", None) + if callable(request_response): + try: + await request_response() + except Exception as exc: # noqa: BLE001 + logger.debug("Realtime request_response failed: %s", exc) # User transcript landed — flush any assistant turn # that was buffered waiting for it. self._user_transcript_pending = False @@ -1084,6 +1266,33 @@ async def _forward_events(self) -> None: current_agent_text += response_text elif ev_type == "speech_started": + # Gate the cancel/flush path with an anti-flicker window + # similar to the pipeline mode. OpenAI's server VAD + # fires ``speech_started`` on echo of the agent's own + # audio in PSTN no-AEC scenarios (carrier loopback + # feeds our outbound mulaw back into the input buffer). + # Without this gate every phantom ``speech_started`` + # cancels the response — most visibly, the + # firstMessage gets truncated mid-sentence. + # + # ``OpenAIRealtimeStreamHandler`` doesn't carry the + # full pipeline TTS-tracking state (no + # ``_is_speaking`` / ``_first_audio_sent_at``), so + # we use the adapter's own response-tracking + # attributes as a proxy. + response_started_at = getattr( + self._adapter, + "_current_response_first_audio_at", + None, + ) + if response_started_at is not None: + elapsed = time.monotonic() - response_started_at + if elapsed < MIN_AGENT_SPEAKING_S_BEFORE_BARGE_IN_NO_AEC: + logger.info( + "Realtime barge-in suppressed (response < gate, %.2fs)", + elapsed, + ) + continue await self.audio_sender.send_clear() await self._adapter.cancel_response() if self.metrics is not None: @@ -1814,6 +2023,13 @@ def __init__( # generic ``audio_*`` marks the Realtime path sends so the two paths # can coexist without name collisions. self._first_message_mark_counter: int = 0 + # Cached result of ``_is_tts_output_format_native_for_carrier()`` + # — settled once at ``start()`` time after ``set_telephony_carrier`` + # has run on the TTS adapter. ``True`` means + # ``_encode_pipeline_audio`` can take the bypass path (raw bytes + # → base64, no resample/encode). Parity with TS + # ``StreamHandler.ttsOutputFormatNativeForCarrier``. + self._tts_output_format_native_for_carrier: bool = False async def start(self) -> None: """Initialize STT/TTS providers, hooks, and start the STT receive loop.""" @@ -1862,6 +2078,25 @@ async def start(self) -> None: "TTS set_telephony_carrier failed; using construction-time format", exc_info=True, ) + # Re-evaluate after set_telephony_carrier so the _encode_pipeline_audio + # fast path is enabled for the current carrier when the adapter + # auto-flipped (or the user constructed with a native format). + # Parity with TS ``StreamHandler.ttsOutputFormatNativeForCarrier``. + self._tts_output_format_native_for_carrier = ( + self._is_tts_output_format_native_for_carrier() + ) + if self._tts_output_format_native_for_carrier: + logger.debug( + "TTS outputFormat matches %s wire codec — bypassing client-side transcode", + "twilio" if self._for_twilio else "telnyx", + ) + # Flip the audio sender into pass-through mode so it stops + # transcoding (16 kHz PCM → mulaw) bytes that are already in + # the carrier's wire format. Mirrors the ConvAI handler's + # ``_native_mulaw_8k`` fast-path and TS ``encodePipelineAudio`` + # bypass. Parity with TS ``StreamHandler.ttsOutputFormatNativeForCarrier``. + if hasattr(self.audio_sender, "_input_is_mulaw_8k"): + self.audio_sender._input_is_mulaw_8k = True # type: ignore[attr-defined] if self._stt is None: logger.warning("Pipeline mode: no STT configured") @@ -2696,6 +2931,22 @@ async def _do_cancel_for_barge_in(self, transcript_text: str) -> None: cancel_event = getattr(self, "_llm_cancel_event", None) if cancel_event is not None: cancel_event.set() + # Force-close any in-flight TTS streaming socket. Without this, + # the firstMessage live ``synthesize`` path (used when the prewarm + # accumulator hadn't completed before pickup) would block on its + # inner ``await ws.recv()`` for up to ``frame_timeout`` (30 s) — + # ``_init_pipeline`` would never return, the STT ``on_transcript`` + # callback would never register, and every subsequent user turn + # would be silently dropped. Provider-duck-typed: adapters that + # don't expose ``cancel_active_stream`` are no-ops here. + # Parity with TS ``StreamHandler.cancelSpeaking``. + _tts = getattr(self, "_tts", None) + _cancel_fn = getattr(_tts, "cancel_active_stream", None) + if callable(_cancel_fn): + try: + _cancel_fn() + except Exception as _exc: + logger.debug("TTS cancel_active_stream raised: %s", _exc) try: await self.audio_sender.send_clear() except Exception as exc: @@ -3438,6 +3689,28 @@ async def _flush_inbound_audio_ring(self) -> None: replayed * 20, ) + def _is_tts_output_format_native_for_carrier(self) -> bool: + """Return True when the TTS adapter's output_format is already in the + carrier's wire codec — meaning no client-side resample/transcode is + needed in ``TwilioAudioSender.send_audio``. + + Twilio expects ``ulaw_8000``; Telnyx expects ``pcm_16000``. Anything + else goes through the normal resample-and-encode path. + + Parity with TS ``StreamHandler.isTtsOutputFormatNativeForCarrier``. + """ + if self._tts is None: + return False + fmt = getattr(self._tts, "output_format", None) + if not isinstance(fmt, str): + return False + carrier = "twilio" if self._for_twilio else "telnyx" + if carrier == "twilio": + return fmt == "ulaw_8000" + if carrier == "telnyx": + return fmt == "pcm_16000" + return False + # 40 ms @ 16 kHz mono PCM16 = 1280 bytes. Sized to mirror the smallest # live-TTS chunk boundary so cancel granularity (mark/clear bookkeeping) # is identical regardless of whether the firstMessage came from the @@ -3455,9 +3728,12 @@ async def _flush_inbound_audio_ring(self) -> None: # deadlock window when a carrier (or a test double) never echoes — # playout may glitch by one chunk on timeout but the call stays alive. _MARK_AWAIT_TIMEOUT_S: float = 0.5 - # Bytes-per-millisecond for a 16 kHz PCM16 mono stream — used by the - # non-Twilio firstMessage pacing path to translate chunk size into a - # playout-duration sleep. 16000 samples/sec × 2 bytes = 32 bytes/ms. + # Bytes-per-millisecond for a 16 kHz PCM16 mono stream. Used by + # ``_send_paced_first_message_bytes`` to translate chunk size into a + # playout-duration sleep so we never deliver faster than the carrier + # can decode + play out (which manifested as severe crackling on the + # HTTP-TTS path with client-side resampling). 16000 samples/sec × 2 + # bytes/sample = 32 bytes/ms. _PCM16_16K_BYTES_PER_MS: int = 32 def _drain_pending_marks(self) -> None: @@ -3588,15 +3864,21 @@ async def _send_paced_first_message_bytes(self, bytes_: bytes) -> bool: self._drain_pending_marks() self._first_message_mark_counter = 0 first_chunk_sent = False - # Once the mark window is first filled we switch to playout-time pacing - # to prevent batch-ACK bursts. Before that we send in burst so the first - # _FIRST_MESSAGE_MARK_WINDOW chunks pre-fill the PSTN jitter buffer. + # Once the mark window is first filled we switch to playout-time + # pacing to prevent batch-ACK bursts from draining the carrier + # jitter buffer. Before that we send in burst so the first + # ``_FIRST_MESSAGE_MARK_WINDOW`` chunks pre-fill the PSTN jitter + # buffer (250–1500 ms). The earlier experiment of pure-burst + # delivery (no per-chunk sleep) produced severe carrier-side + # crackling on the HTTP TTS path (pcm_16000 → mulaw_8000 client- + # side resample) because the burst arrived at Twilio faster than + # its media-stream decoder could process — even though the docs + # say "of any size". The pace-by-playout path is the robust + # default; mark back-pressure remains as an extra guard. initial_fill_complete = False for i in range(0, len(bytes_), self._PREWARM_CHUNK_BYTES): if not self._is_speaking: break # barge-in mid-buffer — stop now - # Back-pressure: if too many marks are unconfirmed, wait. - # Drains immediately on cancel. await self._wait_for_mark_window() if not self._is_speaking: break @@ -3607,24 +3889,44 @@ async def _send_paced_first_message_bytes(self, bytes_: bytes) -> bool: self._aec.push_far_end(chunk) await self.audio_sender.send_audio(chunk) self._mark_first_audio_sent() - mark_future = await self._send_mark_awaitable() + mark_awaitable = await self._send_mark_awaitable() if ( not initial_fill_complete and len(self._pending_marks) >= self._FIRST_MESSAGE_MARK_WINDOW ): initial_fill_complete = True # Telnyx has no mark concept — always pace by playout time. - # Twilio: the first _FIRST_MESSAGE_MARK_WINDOW chunks go out in burst - # to pre-fill the PSTN jitter buffer (250–1500 ms), then playout-time - # pacing kicks in (via the sticky initial_fill_complete flag) to prevent - # batch-ACK bursts from draining the buffer → crackling. - if mark_future is None or initial_fill_complete: - playout_ms = max(1, len(chunk) // self._PCM16_16K_BYTES_PER_MS) + # Twilio: the first ``_FIRST_MESSAGE_MARK_WINDOW`` chunks go + # out in burst to pre-fill the PSTN jitter buffer, then + # playout-time pacing kicks in (via the sticky + # ``initial_fill_complete`` flag) to prevent batch-ACK bursts + # from draining the buffer → crackling. + if mark_awaitable is None or initial_fill_complete: + playout_ms = max( + 1, + len(chunk) // self._PCM16_16K_BYTES_PER_MS, + ) await asyncio.sleep(playout_ms / 1000.0) return first_chunk_sent async def cleanup(self) -> None: """Cancel the STT loop and close STT/TTS/remote-message adapters.""" + # Abort any in-flight LLM stream and close any in-flight TTS WS so + # the run_pipeline_llm / synthesize awaits unblock immediately + # instead of waiting up to 30 s for their own watchdog timers. + # Without this, the carrier's stop event ends the call but a + # pending TTS WS frame-wait fires a stale "LLM loop error" / + # "TTS streaming error" log line tens of seconds later. Parity + # with TS ``StreamHandler.handleStop`` / ``handleWsClose``. + cancel_event = getattr(self, "_llm_cancel_event", None) + if cancel_event is not None: + cancel_event.set() + _tts_cancel = getattr(getattr(self, "_tts", None), "cancel_active_stream", None) + if callable(_tts_cancel): + try: + _tts_cancel() + except Exception: + pass # Drop any pending barge-in timeout BEFORE we tear down metrics / # adapters. Without this, a call that ends while a barge-in is # pending leaves an asyncio.Task scheduled to fire diff --git a/libraries/python/getpatter/telephony/telnyx.py b/libraries/python/getpatter/telephony/telnyx.py index 2d7dc8f1..4f9b5a37 100644 --- a/libraries/python/getpatter/telephony/telnyx.py +++ b/libraries/python/getpatter/telephony/telnyx.py @@ -402,8 +402,9 @@ async def telnyx_stream_bridge( # 8 kHz to match the `streaming_start` PCMU bidirectional # stream — forward bytes as-is. Pipeline and ConvAI still # produce PCM16 that Telnyx accepts when L16 is negotiated. - _input_is_mulaw = ( - getattr(agent, "provider", "openai_realtime") == "openai_realtime" + _input_is_mulaw = getattr(agent, "provider", "openai_realtime") in ( + "openai_realtime", + "openai_realtime_2", ) audio_sender = TelnyxAudioSender( websocket, input_is_mulaw_8k=_input_is_mulaw @@ -623,6 +624,7 @@ async def _telnyx_stop_recording() -> None: # 20 ms → PCMU 8 kHz. OpenAI Realtime with this # codec forwards bytes pass-through on both legs. audio_format="g711_ulaw", + pop_prewarmed_connections=pop_prewarmed_connections, ) # Inherit patter.side from the parent Patter instance so all diff --git a/libraries/python/getpatter/telephony/twilio.py b/libraries/python/getpatter/telephony/twilio.py index 4b8f34c3..075f2508 100644 --- a/libraries/python/getpatter/telephony/twilio.py +++ b/libraries/python/getpatter/telephony/twilio.py @@ -9,7 +9,6 @@ import re import time from collections import deque -from urllib.parse import quote from getpatter.observability.attributes import patter_call_scope from getpatter.stream_handler import ( @@ -91,11 +90,15 @@ def twilio_webhook_handler( # Lazy import — provider adapter may be created by the parallel agent from getpatter.providers.twilio_adapter import TwilioAdapter # type: ignore[import] - stream_url = ( - f"wss://{webhook_base_url}/ws/stream/{call_sid}" - f"?caller={quote(caller)}&callee={quote(callee)}" + # Twilio Media Streams strips the query string from ```` + # before opening the WS, so caller/callee must travel as + # ```` children — the bridge then reads them from + # ``start.customParameters`` on the WS ``start`` frame. + stream_url = f"wss://{webhook_base_url}/ws/stream/{call_sid}" + return TwilioAdapter.generate_stream_twiml( + stream_url, + parameters={"caller": caller, "callee": callee}, ) - return TwilioAdapter.generate_stream_twiml(stream_url) # --------------------------------------------------------------------------- @@ -317,6 +320,15 @@ async def twilio_stream_bridge( start_data = data.get("start", {}) call_sid_actual = start_data.get("callSid", "") custom_params: dict = start_data.get("customParameters", {}) + # Inbound path: caller / callee travel via TwiML + # ```` tags (Twilio strips query params from + # ````), so the WS-level query-param read + # above lands empty. Fall back to ``customParameters`` on + # the ``start`` frame. + if not caller: + caller = custom_params.get("caller", "") or caller + if not callee: + callee = custom_params.get("callee", "") or callee # Single INFO line per call-start — full context in one place. _mode = ( @@ -405,7 +417,7 @@ async def twilio_stream_bridge( # to emit g711_ulaw @ 8 kHz directly (see below), so for that # provider we skip the built-in PCM→mulaw transcoding path. # Pipeline / ConvAI still produce PCM16 @ 16 kHz. - _input_is_mulaw = provider == "openai_realtime" + _input_is_mulaw = provider in ("openai_realtime", "openai_realtime_2") audio_sender = TwilioAudioSender( websocket, stream_sid, input_is_mulaw_8k=_input_is_mulaw ) @@ -516,6 +528,7 @@ async def _twilio_hangup(): # produces a deep, slurred voice. audio_format="g711_ulaw", speech_events=speech_events, + pop_prewarmed_connections=pop_prewarmed_connections, ) # Inherit patter.side from the parent Patter instance so all diff --git a/libraries/python/getpatter/tts/elevenlabs.py b/libraries/python/getpatter/tts/elevenlabs.py index 2a13f3a3..d4e5957f 100644 --- a/libraries/python/getpatter/tts/elevenlabs.py +++ b/libraries/python/getpatter/tts/elevenlabs.py @@ -57,7 +57,7 @@ def __init__( *, voice_id: str = "EXAVITQu4vr4xnSDxMaL", model_id: str = "eleven_flash_v2_5", - output_format: str = "pcm_16000", + output_format: str | None = None, language_code: str | None = None, voice_settings: dict | None = None, auto_mode: bool = True, @@ -68,13 +68,23 @@ def __init__( # (chunking is driven by ``chunk_length_schedule`` on that path). chunk_size: int | None = None, ) -> None: + # CRITICAL: only forward ``output_format`` when the caller actually + # passed one. Forwarding a fallback (``"pcm_16000"``) would flip the + # parent's ``_output_format_explicit`` flag to ``True`` and disable the + # carrier-aware auto-flip in ``set_telephony_carrier`` — the prewarm + # path on Twilio would keep emitting PCM16 16 kHz and pay the + # client-side resample/encode that produced the "audio a scatti" + # user report. Leaving the field out lets the parent default to + # PCM_16000 with the explicit-flag cleared so the carrier hook can + # flip to ulaw_8000 at call time. Parity with TS ``tts/elevenlabs.ts``. kwargs: dict = { "api_key": _resolve_api_key(api_key), "voice_id": voice_id, "model_id": model_id, - "output_format": output_format, "auto_mode": auto_mode, } + if output_format is not None: + kwargs["output_format"] = output_format if voice_settings is not None: kwargs["voice_settings"] = voice_settings if language_code is not None: diff --git a/libraries/python/pyproject.toml b/libraries/python/pyproject.toml index 618e6ea0..083b2e41 100644 --- a/libraries/python/pyproject.toml +++ b/libraries/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "getpatter" -version = "0.6.1" +version = "0.6.2" description = "Open-source voice AI SDK — connect any AI agent to real phone calls in 4 lines of code" readme = "README.md" license = { text = "MIT" } diff --git a/libraries/python/tests/test_local_mode.py b/libraries/python/tests/test_local_mode.py index bcf30fd5..08fc7b42 100644 --- a/libraries/python/tests/test_local_mode.py +++ b/libraries/python/tests/test_local_mode.py @@ -35,7 +35,9 @@ def _twilio_phone(**kwargs) -> Patter: def test_local_config_defaults(): - cfg = LocalConfig(telephony_provider="twilio", phone_number="+1555", webhook_url="x.ngrok.io") + cfg = LocalConfig( + telephony_provider="twilio", phone_number="+1555", webhook_url="x.ngrok.io" + ) assert cfg.telephony_provider == "twilio" assert cfg.openai_key == "" assert cfg.twilio_sid == "" @@ -93,7 +95,9 @@ def test_agent_pipeline_provider(): ) assert a.provider == "pipeline" assert a.voice == "21m00Tcm4TlvDq8ikWAM" - assert a.model == "gpt-4o-mini-realtime-preview" # model field still present, unused in pipeline mode + assert ( + a.model == "gpt-4o-mini-realtime-preview" + ) # model field still present, unused in pipeline mode def test_agent_factory_pipeline_provider(): @@ -182,11 +186,18 @@ async def test_serve_calls_embedded_server(): mock_server = MagicMock() mock_server.start = AsyncMock() - with patch("getpatter.server.EmbeddedServer", return_value=mock_server) as MockServer: + with patch( + "getpatter.server.EmbeddedServer", return_value=mock_server + ) as MockServer: await phone.serve(agent, port=9000) MockServer.assert_called_once_with( - config=phone._local_config, agent=agent, recording=False, voicemail_message="", pricing=None, dashboard=True, + config=phone._local_config, + agent=agent, + recording=False, + voicemail_message="", + pricing=None, + dashboard=True, dashboard_token="", ) mock_server.start.assert_called_once_with(port=9000) @@ -241,9 +252,13 @@ def test_twilio_webhook_handler_url(): MockAdapter.generate_stream_twiml.assert_called_once() call_args = MockAdapter.generate_stream_twiml.call_args[0][0] - assert call_args.startswith("wss://abc.ngrok.io/ws/stream/CA123") - assert "caller=" in call_args - assert "callee=" in call_args + # Stream URL is the bare wss endpoint — caller/callee no longer ride + # as query params (Twilio strips them); they travel as a TwiML + # ```` child of ```` via the ``parameters`` kwarg. + assert call_args == "wss://abc.ngrok.io/ws/stream/CA123" + params = MockAdapter.generate_stream_twiml.call_args.kwargs.get("parameters", {}) + assert params.get("caller") == "+14155551234" + assert params.get("callee") == "+15550001111" assert result == "" @@ -267,7 +282,10 @@ def test_telnyx_webhook_handler_structure(): assert any(c["command"] == "answer" for c in commands) stream_cmd = next((c for c in commands if c["command"] == "stream_start"), None) assert stream_cmd is not None - assert "wss://abc.ngrok.io/ws/telnyx/stream/ctrl_123" in stream_cmd["params"]["stream_url"] + assert ( + "wss://abc.ngrok.io/ws/telnyx/stream/ctrl_123" + in stream_cmd["params"]["stream_url"] + ) # --------------------------------------------------------------------------- @@ -314,16 +332,20 @@ async def test_twilio_stream_bridge_pipeline_sends_audio_to_stt(): agent = Agent(system_prompt="test", provider="pipeline") # Build a fake WebSocket that returns start then a media event then stop - start_payload = json.dumps({ - "event": "start", - "streamSid": "SID123", - "start": {"callSid": "CA_test"}, - }) + start_payload = json.dumps( + { + "event": "start", + "streamSid": "SID123", + "start": {"callSid": "CA_test"}, + } + ) mulaw_bytes = b"\x00" * 160 - media_payload = json.dumps({ - "event": "media", - "media": {"payload": base64.b64encode(mulaw_bytes).decode()}, - }) + media_payload = json.dumps( + { + "event": "media", + "media": {"payload": base64.b64encode(mulaw_bytes).decode()}, + } + ) stop_payload = json.dumps({"event": "stop"}) messages = [start_payload, media_payload, stop_payload] @@ -351,7 +373,6 @@ async def send_text(self, data): fake_ws = FakeWS() # Patch DeepgramSTT and ElevenLabsTTS so no real connections are made - import getpatter.telephony.twilio as twilio_mod mock_stt = AsyncMock() mock_stt.connect = AsyncMock() @@ -372,7 +393,9 @@ async def fake_receive(): # bridge instantiates the plain DeepgramSTT constructor — not for_twilio. with ( patch("getpatter.providers.deepgram_stt.DeepgramSTT", return_value=mock_stt), - patch("getpatter.providers.elevenlabs_tts.ElevenLabsTTS", return_value=mock_tts), + patch( + "getpatter.providers.elevenlabs_tts.ElevenLabsTTS", return_value=mock_tts + ), ): # Run with a short timeout — we only care that it starts up correctly try: @@ -534,15 +557,19 @@ async def test_dtmf_event_fires_transcript_callback(): agent = Agent(system_prompt="test", provider="pipeline") - start_payload = json.dumps({ - "event": "start", - "streamSid": "SID_dtmf", - "start": {"callSid": "CA_dtmf"}, - }) - dtmf_payload = json.dumps({ - "event": "dtmf", - "dtmf": {"track": "inbound_track", "digit": "5"}, - }) + start_payload = json.dumps( + { + "event": "start", + "streamSid": "SID_dtmf", + "start": {"callSid": "CA_dtmf"}, + } + ) + dtmf_payload = json.dumps( + { + "event": "dtmf", + "dtmf": {"track": "inbound_track", "digit": "5"}, + } + ) stop_payload = json.dumps({"event": "stop"}) messages = [start_payload, dtmf_payload, stop_payload] @@ -587,7 +614,9 @@ async def fake_receive(): with ( patch("getpatter.providers.deepgram_stt.DeepgramSTT", return_value=mock_stt), - patch("getpatter.providers.elevenlabs_tts.ElevenLabsTTS", return_value=mock_tts), + patch( + "getpatter.providers.elevenlabs_tts.ElevenLabsTTS", return_value=mock_tts + ), ): try: await asyncio.wait_for( @@ -612,7 +641,9 @@ async def fake_receive(): def test_dtmf_event_format(): """DTMF event payload includes digit under dtmf.digit.""" - raw = json.loads('{"event": "dtmf", "dtmf": {"track": "inbound_track", "digit": "1"}}') + raw = json.loads( + '{"event": "dtmf", "dtmf": {"track": "inbound_track", "digit": "1"}}' + ) assert raw["event"] == "dtmf" assert raw["dtmf"]["digit"] == "1" @@ -624,7 +655,9 @@ def test_dtmf_event_format(): def test_mark_event_format(): """Mark events from Twilio include mark.name.""" - raw = json.loads('{"event": "mark", "streamSid": "SID", "mark": {"name": "audio_3"}}') + raw = json.loads( + '{"event": "mark", "streamSid": "SID", "mark": {"name": "audio_3"}}' + ) assert raw["event"] == "mark" assert raw["mark"]["name"] == "audio_3" @@ -637,11 +670,13 @@ async def test_mark_events_sent_after_audio(): agent = Agent(system_prompt="test", provider="openai_realtime") - start_payload = json.dumps({ - "event": "start", - "streamSid": "SID_mark", - "start": {"callSid": "CA_mark"}, - }) + start_payload = json.dumps( + { + "event": "start", + "streamSid": "SID_mark", + "start": {"callSid": "CA_mark"}, + } + ) stop_payload = json.dumps({"event": "stop"}) messages = [start_payload, stop_payload] idx = 0 @@ -683,8 +718,11 @@ async def fake_events(): mock_adapter.receive_events = MagicMock(return_value=fake_events()) mock_adapter.send_text = AsyncMock() + # Both ``openai_realtime`` and ``openai_realtime_2`` engines now + # route through ``OpenAIRealtime2Adapter`` after the GA deprecation + # of the Beta endpoint — patch the GA adapter so the mock is reached. with patch( - "getpatter.providers.openai_realtime.OpenAIRealtimeAdapter", + "getpatter.providers.openai_realtime_2.OpenAIRealtime2Adapter", return_value=mock_adapter, ): try: @@ -701,7 +739,9 @@ async def fake_events(): sent_events = [json.loads(s) for s in fake_ws.sent] mark_events = [e for e in sent_events if e.get("event") == "mark"] - assert len(mark_events) >= 1, f"Expected at least one mark event, got: {sent_events}" + assert len(mark_events) >= 1, ( + f"Expected at least one mark event, got: {sent_events}" + ) assert mark_events[0]["mark"]["name"].startswith("audio_") @@ -718,14 +758,16 @@ async def test_custom_params_passed_to_on_call_start(): agent = Agent(system_prompt="test", provider="pipeline") - start_payload = json.dumps({ - "event": "start", - "streamSid": "SID_params", - "start": { - "callSid": "CA_params", - "customParameters": {"agent_name": "Aria", "language": "it"}, - }, - }) + start_payload = json.dumps( + { + "event": "start", + "streamSid": "SID_params", + "start": { + "callSid": "CA_params", + "customParameters": {"agent_name": "Aria", "language": "it"}, + }, + } + ) stop_payload = json.dumps({"event": "stop"}) messages = [start_payload, stop_payload] idx = 0 @@ -768,8 +810,13 @@ async def fake_receive(): mock_tts.close = AsyncMock() with ( - patch("getpatter.providers.deepgram_stt.DeepgramSTT.for_twilio", return_value=mock_stt), - patch("getpatter.providers.elevenlabs_tts.ElevenLabsTTS", return_value=mock_tts), + patch( + "getpatter.providers.deepgram_stt.DeepgramSTT.for_twilio", + return_value=mock_stt, + ), + patch( + "getpatter.providers.elevenlabs_tts.ElevenLabsTTS", return_value=mock_tts + ), ): try: await asyncio.wait_for( @@ -786,7 +833,10 @@ async def fake_receive(): except asyncio.TimeoutError: pass - assert call_start_data.get("custom_params") == {"agent_name": "Aria", "language": "it"} + assert call_start_data.get("custom_params") == { + "agent_name": "Aria", + "language": "it", + } assert call_start_data.get("call_id") == "CA_params" @@ -800,14 +850,18 @@ def test_custom_params_in_call_start_format(): def test_custom_params_extracted_from_start_event(): """customParameters from the TwiML start event are parsed correctly.""" - raw = json.loads(json.dumps({ - "event": "start", - "streamSid": "SID", - "start": { - "callSid": "CA123", - "customParameters": {"foo": "bar", "baz": "42"}, - }, - })) + raw = json.loads( + json.dumps( + { + "event": "start", + "streamSid": "SID", + "start": { + "callSid": "CA123", + "customParameters": {"foo": "bar", "baz": "42"}, + }, + } + ) + ) start_data = raw.get("start", {}) custom_params = start_data.get("customParameters", {}) assert custom_params == {"foo": "bar", "baz": "42"} diff --git a/libraries/python/tests/test_metrics.py b/libraries/python/tests/test_metrics.py index 604e1557..1ebe33db 100644 --- a/libraries/python/tests/test_metrics.py +++ b/libraries/python/tests/test_metrics.py @@ -1,6 +1,5 @@ """Tests for the CallMetricsAccumulator.""" - import pytest from getpatter.models import CallMetrics, CostBreakdown, LatencyBreakdown, TurnMetrics @@ -89,6 +88,85 @@ def test_interrupted_turn_no_active_turn(self): turn = acc.record_turn_interrupted() assert turn is None + def test_record_turn_complete_is_noop_after_interrupted(self): + """Late ``record_turn_complete`` after ``record_turn_interrupted`` on + the same turn must be a no-op. + + Repro of the VAD-barge-in / pipeline-LLM race documented in + ``BUGS.md`` (2026-05-05). The barge-in path closes the turn with + ``record_turn_interrupted`` while the in-flight pipeline LLM + stream eventually unwinds and reaches ``record_turn_complete``. + Without the guard, the late call would push a phantom turn with + ``user_text=''`` (since ``_reset_turn_state`` cleared the field) + and ``agent_text`` from the cancelled LLM stream. + """ + acc = self._make_accumulator() + + acc.start_turn() + acc.record_stt_complete("Hello", audio_seconds=1.0) + interrupted = acc.record_turn_interrupted() + assert interrupted is not None + assert interrupted.user_text == "Hello" + assert interrupted.agent_text == "[interrupted]" + + # Late pipeline-LLM unwind reaches record_turn_complete with the + # cancelled responseText — must be silently dropped. + late = acc.record_turn_complete("partial LLM output") + assert late is None + + # Only the interrupted turn is recorded. + result = acc.end_call() + assert len(result.turns) == 1 + assert result.turns[0].agent_text == "[interrupted]" + assert result.turns[0].user_text == "Hello" + + def test_record_turn_interrupted_is_noop_after_complete(self): + """Bidirectional parity: a late ``record_turn_interrupted`` after + ``record_turn_complete`` on the same turn must also be a no-op. + + The current caller ordering can't trigger this (the VAD bargein + path fires the interrupt FIRST and the LLM-unwind path then + calls complete second, guarded by the existing one-directional + guard). The symmetric guard hardens the accumulator against a + future refactor that reorders those paths. + """ + acc = self._make_accumulator() + + acc.start_turn() + acc.record_stt_complete("Hello", audio_seconds=1.0) + completed = acc.record_turn_complete("Hi there") + assert completed is not None + assert completed.user_text == "Hello" + assert completed.agent_text == "Hi there" + + # Late VAD-bargein interruption arrives after the complete — + # must be silently dropped. + late = acc.record_turn_interrupted() + assert late is None + + # Only the completed turn is recorded. + result = acc.end_call() + assert len(result.turns) == 1 + assert result.turns[0].agent_text == "Hi there" + + def test_record_turn_complete_rearms_after_start_turn(self): + """A fresh ``start_turn`` must re-arm the accumulator so the next + ``record_turn_complete`` is allowed again.""" + acc = self._make_accumulator() + + acc.start_turn() + acc.record_stt_complete("Hello") + acc.record_turn_interrupted() + assert acc.record_turn_complete("dropped") is None + + # New turn begins. + acc.start_turn() + acc.record_stt_complete("Second turn") + completed = acc.record_turn_complete("Reply") + assert completed is not None + assert completed.user_text == "Second turn" + assert completed.agent_text == "Reply" + def test_stt_audio_bytes_tracking(self): acc = self._make_accumulator() # Twilio mulaw: 8kHz, 1 byte/sample → 8000 bytes = 1 second diff --git a/libraries/python/tests/test_new_features.py b/libraries/python/tests/test_new_features.py index b3a9967c..c3ebf3e6 100644 --- a/libraries/python/tests/test_new_features.py +++ b/libraries/python/tests/test_new_features.py @@ -8,7 +8,7 @@ import pytest -from getpatter import OpenAIRealtime, Patter, Twilio, tool +from getpatter import OpenAIRealtime, Patter, Twilio from getpatter.models import Agent @@ -24,6 +24,8 @@ def _local_phone(webhook_url="abc.ngrok.io"): def _local_agent(phone: Patter) -> Agent: """Build an OpenAI Realtime agent for the tests below.""" return phone.agent(engine=OpenAIRealtime(api_key="sk_test"), system_prompt="Test") + + from getpatter.telephony.twilio import _TRANSFER_CALL_TOOL, _END_CALL_TOOL @@ -65,11 +67,20 @@ def test_transfer_call_tool_injected_when_no_agent_tools(): def test_transfer_call_tool_injected_alongside_agent_tools(): """transfer_call is appended after agent-defined tools.""" - user_tool = {"name": "lookup", "description": "Look up", "parameters": {}, "webhook_url": "https://x.com"} + user_tool = { + "name": "lookup", + "description": "Look up", + "parameters": {}, + "webhook_url": "https://x.com", + } agent = Agent(system_prompt="Test", tools=[user_tool]) # Simulate what the handler does agent_tools = [ - {"name": t["name"], "description": t.get("description", ""), "parameters": t.get("parameters", {})} + { + "name": t["name"], + "description": t.get("description", ""), + "parameters": t.get("parameters", {}), + } for t in (agent.tools or []) ] openai_tools = agent_tools + [_TRANSFER_CALL_TOOL] @@ -127,9 +138,8 @@ def test_serve_passes_recording_to_server(): MockServer.return_value = mock_instance import asyncio - asyncio.run( - phone.serve(agent, recording=True) - ) + + asyncio.run(phone.serve(agent, recording=True)) MockServer.assert_called_once() call_args = MockServer.call_args @@ -167,6 +177,7 @@ def test_call_accepts_machine_detection_param(): agent = _local_agent(phone) # Verify the parameter is accepted by the function signature import inspect + sig = inspect.signature(phone.call) assert "machine_detection" in sig.parameters @@ -182,16 +193,18 @@ def test_machine_detection_adds_params_to_twilio_call(): MockAdapter.return_value = mock_instance import asyncio - asyncio.run( - phone.call(to="+39123456789", agent=agent, machine_detection=True) - ) + + asyncio.run(phone.call(to="+39123456789", agent=agent, machine_detection=True)) mock_instance.initiate_call.assert_called_once() _, kwargs = mock_instance.initiate_call.call_args extra = kwargs.get("extra_params", {}) - assert extra.get("MachineDetection") == "DetectMessageEnd" - assert extra.get("AsyncAmd") == "true" - assert "AsyncAmdStatusCallback" in extra + # twilio-python's ``calls.create(**kwargs)`` accepts snake_case + # only — PascalCase raises ``TypeError: unexpected keyword + # argument``. Defaults must therefore be snake_case at the source. + assert extra.get("machine_detection") == "DetectMessageEnd" + assert extra.get("async_amd") == "true" + assert "async_amd_status_callback" in extra def test_amd_callback_url_uses_webhook_host(): @@ -205,13 +218,12 @@ def test_amd_callback_url_uses_webhook_host(): MockAdapter.return_value = mock_instance import asyncio - asyncio.run( - phone.call(to="+39123456789", agent=agent, machine_detection=True) - ) + + asyncio.run(phone.call(to="+39123456789", agent=agent, machine_detection=True)) _, kwargs = mock_instance.initiate_call.call_args extra = kwargs.get("extra_params", {}) - assert "my.ngrok.io" in extra.get("AsyncAmdStatusCallback", "") + assert "my.ngrok.io" in extra.get("async_amd_status_callback", "") def test_amd_webhook_endpoint_exists(): @@ -244,17 +256,18 @@ def test_machine_detection_false_no_extra_params(): MockAdapter.return_value = mock_instance import asyncio - asyncio.run( - phone.call(to="+39123456789", agent=agent, machine_detection=False) - ) + + asyncio.run(phone.call(to="+39123456789", agent=agent, machine_detection=False)) _, kwargs = mock_instance.initiate_call.call_args extra = kwargs.get("extra_params", {}) # AMD-specific params must be absent when machine_detection=False. - assert "MachineDetection" not in extra - assert "AsyncAmd" not in extra - # StatusCallback is always wired (BUG #06 — dashboard sees failures). - assert extra.get("StatusCallback", "").endswith("/webhooks/twilio/status") + assert "machine_detection" not in extra + assert "async_amd" not in extra + # status_callback is always wired (BUG #06 — dashboard sees failures). + # Snake_case is mandatory: twilio-python's ``calls.create`` + # rejects PascalCase with ``TypeError: unexpected keyword``. + assert extra.get("status_callback", "").endswith("/webhooks/twilio/status") # --------------------------------------------------------------------------- @@ -280,10 +293,19 @@ def test_end_call_tool_reason_is_optional(): def test_end_call_tool_injected_in_openai_tools(): """_END_CALL_TOOL is injected alongside _TRANSFER_CALL_TOOL in OpenAI tool list.""" - user_tool = {"name": "lookup", "description": "Look up", "parameters": {}, "webhook_url": "https://x.com"} + user_tool = { + "name": "lookup", + "description": "Look up", + "parameters": {}, + "webhook_url": "https://x.com", + } agent = Agent(system_prompt="Test", tools=[user_tool]) agent_tools = [ - {"name": t["name"], "description": t.get("description", ""), "parameters": t.get("parameters", {})} + { + "name": t["name"], + "description": t.get("description", ""), + "parameters": t.get("parameters", {}), + } for t in (agent.tools or []) ] openai_tools = agent_tools + [_TRANSFER_CALL_TOOL, _END_CALL_TOOL] @@ -308,6 +330,7 @@ def test_end_call_tool_injected_with_no_agent_tools(): def test_voicemail_message_param_on_call(): """Patter.call() accepts voicemail_message parameter.""" import inspect + phone = _local_phone() sig = inspect.signature(phone.call) assert "voicemail_message" in sig.parameters @@ -316,6 +339,7 @@ def test_voicemail_message_param_on_call(): def test_voicemail_message_param_on_serve(): """Patter.serve() accepts voicemail_message parameter.""" import inspect + phone = _local_phone() sig = inspect.signature(phone.serve) assert "voicemail_message" in sig.parameters @@ -334,7 +358,9 @@ def test_embedded_server_accepts_voicemail_message(): webhook_url="abc.ngrok.io", ) agent = Agent(system_prompt="Test") - server = EmbeddedServer(config=config, agent=agent, voicemail_message="Please call back.") + server = EmbeddedServer( + config=config, agent=agent, voicemail_message="Please call back." + ) assert server.voicemail_message == "Please call back." @@ -366,9 +392,8 @@ def test_serve_passes_voicemail_message_to_server(): MockServer.return_value = mock_instance import asyncio - asyncio.run( - phone.serve(agent, voicemail_message="Hi, please call back.") - ) + + asyncio.run(phone.serve(agent, voicemail_message="Hi, please call back.")) MockServer.assert_called_once() call_kwargs = MockServer.call_args.kwargs @@ -480,7 +505,9 @@ def test_resolve_variables_replaces_placeholders(): """_resolve_variables substitutes {key} with corresponding values.""" from getpatter.telephony.twilio import _resolve_variables - result = _resolve_variables("Hello {name}, order #{order_id}!", {"name": "Mario", "order_id": "42"}) + result = _resolve_variables( + "Hello {name}, order #{order_id}!", {"name": "Mario", "order_id": "42"} + ) assert result == "Hello Mario, order #42!" @@ -530,11 +557,13 @@ async def on_message(data): ws.query_params = {"caller": "+1", "callee": "+2"} events = [ - json.dumps({ - "event": "start", - "streamSid": "SID", - "start": {"callSid": "CA1", "customParameters": {}}, - }), + json.dumps( + { + "event": "start", + "streamSid": "SID", + "start": {"callSid": "CA1", "customParameters": {}}, + } + ), json.dumps({"event": "stop"}), ] ws.receive_text = AsyncMock(side_effect=events) @@ -555,9 +584,12 @@ async def fake_receive(): mock_stt.receive_transcripts = fake_receive - with patch("getpatter.telephony.twilio._create_stt_from_config", return_value=None), \ - patch("getpatter.telephony.twilio._create_tts_from_config", return_value=None): + with ( + patch("getpatter.telephony.twilio._create_stt_from_config", return_value=None), + patch("getpatter.telephony.twilio._create_tts_from_config", return_value=None), + ): from getpatter.telephony.twilio import twilio_stream_bridge + try: await asyncio.wait_for( twilio_stream_bridge( @@ -597,11 +629,13 @@ async def on_call_end(data): ws.query_params = {"caller": "+1", "callee": "+2"} events = [ - json.dumps({ - "event": "start", - "streamSid": "SID", - "start": {"callSid": "CA1", "customParameters": {}}, - }), + json.dumps( + { + "event": "start", + "streamSid": "SID", + "start": {"callSid": "CA1", "customParameters": {}}, + } + ), json.dumps({"event": "stop"}), ] ws.receive_text = AsyncMock(side_effect=events) @@ -620,8 +654,12 @@ async def fake_receive_events(): mock_adapter.receive_events = fake_receive_events - with patch("getpatter.providers.openai_realtime.OpenAIRealtimeAdapter", return_value=mock_adapter): + with patch( + "getpatter.providers.openai_realtime.OpenAIRealtimeAdapter", + return_value=mock_adapter, + ): from getpatter.telephony.twilio import twilio_stream_bridge + try: await asyncio.wait_for( twilio_stream_bridge( diff --git a/libraries/python/tests/test_prewarm.py b/libraries/python/tests/test_prewarm.py index 65376b77..ee013f03 100644 --- a/libraries/python/tests/test_prewarm.py +++ b/libraries/python/tests/test_prewarm.py @@ -122,13 +122,86 @@ async def _wait_for_tasks(phone: Patter, timeout: float = 1.0) -> None: async def test_default_prewarm_flag_is_true() -> None: - """``Agent.prewarm`` defaults to True; ``prewarm_first_message`` defaults - to False to preserve the prior cost surface (opt-in for the TTS bill).""" + """``Agent.prewarm`` defaults to True; ``prewarm_first_message`` + defaults to False at the dataclass level to preserve backwards- + compatible behaviour for direct ``Agent(...)`` construction. The + recommended :meth:`Patter.agent` factory flips it to True for + pipeline mode — see ``test_factory_defaults_prewarm_first_message_*`` + below. + """ agent = Agent(system_prompt="hi", first_message="hello") assert agent.prewarm is True assert agent.prewarm_first_message is False +async def test_prewarm_first_message_opt_out() -> None: + """Callers can disable greeting pre-rendering with + ``prewarm_first_message=False`` to restore the pre-0.6.2 cost surface + (no TTS bill on un-answered calls).""" + agent = Agent( + system_prompt="hi", + first_message="hello", + prewarm_first_message=False, + ) + assert agent.prewarm_first_message is False + + +async def test_factory_defaults_prewarm_first_message_false_in_pipeline_mode() -> None: + """``Patter.agent(...)`` factory defaults prewarm_first_message to False + (reverted from True in 0.6.2 acceptance — opt-in only). + Parity with the TypeScript factory in ``client.ts``.""" + phone = _make_patter() + stt = StubSTT() + tts = StubTTS() + llm = StubLLM() + agent = phone.agent(system_prompt="hi", stt=stt, tts=tts, llm=llm) + assert agent.provider == "pipeline" + assert agent.prewarm_first_message is False + + +async def test_factory_does_not_default_prewarm_in_realtime_mode() -> None: + """``Patter.agent(...)`` factory leaves prewarm OFF on realtime / + ConvAI provider modes — those handlers never consume the cache, so + enabling it would only burn TTS spend on un-answered rings.""" + from getpatter.engines.openai import Realtime as OpenAIRealtime + + phone = _make_patter() + agent = phone.agent( + system_prompt="hi", + engine=OpenAIRealtime(api_key="sk-test"), + ) + assert agent.provider == "openai_realtime" + assert agent.prewarm_first_message is False + + +async def test_factory_respects_explicit_prewarm_first_message_value() -> None: + """Explicit kwarg always wins over the factory's mode-derived default.""" + from getpatter.engines.openai import Realtime as OpenAIRealtime + + phone = _make_patter() + stt = StubSTT() + tts = StubTTS() + llm = StubLLM() + # Pipeline mode, but caller explicitly opts out. + pipeline_opted_out = phone.agent( + system_prompt="hi", + stt=stt, + tts=tts, + llm=llm, + prewarm_first_message=False, + ) + assert pipeline_opted_out.prewarm_first_message is False + # Realtime mode, but caller explicitly opts in (the WARN guard in + # ``_spawn_prewarm_first_message`` will still suppress the synth, + # but the flag stays at the user's chosen value). + realtime_opted_in = phone.agent( + system_prompt="hi", + engine=OpenAIRealtime(api_key="sk-test"), + prewarm_first_message=True, + ) + assert realtime_opted_in.prewarm_first_message is True + + async def test_provider_warmup_default_is_noop() -> None: """The bare ``STTProvider`` / ``TTSProvider`` subclasses inherit a no-op ``warmup`` so providers that don't override it never raise.""" diff --git a/libraries/python/tests/test_twilio_handler.py b/libraries/python/tests/test_twilio_handler.py index f96100d2..7027b1bc 100644 --- a/libraries/python/tests/test_twilio_handler.py +++ b/libraries/python/tests/test_twilio_handler.py @@ -1,7 +1,6 @@ """Tests for Twilio webhook handler.""" -import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch # --------------------------------------------------------------------------- @@ -74,25 +73,29 @@ def test_stream_url_has_ws_stream_path(): def test_stream_url_contains_caller_param(): - """Stream URL includes caller query param.""" + """caller travels as a TwiML ```` (Twilio strips URL query).""" with patch("getpatter.providers.twilio_adapter.TwilioAdapter") as MockAdapter: MockAdapter.generate_stream_twiml.return_value = "" from getpatter.telephony.twilio import twilio_webhook_handler twilio_webhook_handler("CA123", "+39111", "+16592", "abc.ngrok.io") - url = MockAdapter.generate_stream_twiml.call_args[0][0] - assert "caller=" in url + params = MockAdapter.generate_stream_twiml.call_args.kwargs.get( + "parameters", {} + ) + assert params.get("caller") == "+39111" def test_stream_url_contains_callee_param(): - """Stream URL includes callee query param.""" + """callee travels as a TwiML ```` (Twilio strips URL query).""" with patch("getpatter.providers.twilio_adapter.TwilioAdapter") as MockAdapter: MockAdapter.generate_stream_twiml.return_value = "" from getpatter.telephony.twilio import twilio_webhook_handler twilio_webhook_handler("CA123", "+39111", "+16592", "abc.ngrok.io") - url = MockAdapter.generate_stream_twiml.call_args[0][0] - assert "callee=" in url + params = MockAdapter.generate_stream_twiml.call_args.kwargs.get( + "parameters", {} + ) + assert params.get("callee") == "+16592" # --------------------------------------------------------------------------- diff --git a/libraries/python/tests/test_validation_guardrails.py b/libraries/python/tests/test_validation_guardrails.py index c93486ba..d6a4a016 100644 --- a/libraries/python/tests/test_validation_guardrails.py +++ b/libraries/python/tests/test_validation_guardrails.py @@ -7,15 +7,12 @@ import pytest from getpatter import ( - DeepgramSTT, ElevenLabsTTS, OpenAIRealtime, Patter, Telnyx, - Tool, Twilio, guardrail, - tool, ) from getpatter.models import Agent, Guardrail @@ -215,9 +212,7 @@ def test_call_validates_e164_no_plus(): phone = _local_phone() agent = phone.agent(engine=OpenAIRealtime(api_key="sk"), system_prompt="test") with pytest.raises(ValueError, match="E.164"): - asyncio.run( - phone.call(to="0039123456789", agent=agent) - ) + asyncio.run(phone.call(to="0039123456789", agent=agent)) def test_call_validates_e164_empty(): @@ -225,9 +220,7 @@ def test_call_validates_e164_empty(): phone = _local_phone() agent = phone.agent(engine=OpenAIRealtime(api_key="sk"), system_prompt="test") with pytest.raises(ValueError, match="E.164"): - asyncio.run( - phone.call(to="", agent=agent) - ) + asyncio.run(phone.call(to="", agent=agent)) def test_call_validates_e164_non_string(): @@ -235,9 +228,7 @@ def test_call_validates_e164_non_string(): phone = _local_phone() agent = phone.agent(engine=OpenAIRealtime(api_key="sk"), system_prompt="test") with pytest.raises(ValueError, match="E.164"): - asyncio.run( - phone.call(to=12345, agent=agent) - ) + asyncio.run(phone.call(to=12345, agent=agent)) def test_call_valid_e164_accepted(): @@ -252,9 +243,7 @@ def test_call_valid_e164_accepted(): mock_instance.initiate_call = AsyncMock(return_value="CA123") MockAdapter.return_value = mock_instance - asyncio.run( - phone.call(to="+39123456789", agent=agent) - ) + asyncio.run(phone.call(to="+39123456789", agent=agent)) mock_instance.initiate_call.assert_called_once() @@ -455,11 +444,13 @@ async def test_guardrail_triggers_cancel_and_replacement(): ], ) - start_payload = json.dumps({ - "event": "start", - "streamSid": "SID_guard", - "start": {"callSid": "CA_guard", "customParameters": {}}, - }) + start_payload = json.dumps( + { + "event": "start", + "streamSid": "SID_guard", + "start": {"callSid": "CA_guard", "customParameters": {}}, + } + ) stop_payload = json.dumps({"event": "stop"}) messages = [start_payload, stop_payload] idx = 0 @@ -498,7 +489,14 @@ async def fake_events(): mock_adapter.receive_events = MagicMock(return_value=fake_events()) - with patch("getpatter.providers.openai_realtime.OpenAIRealtimeAdapter", return_value=mock_adapter): + # ``stream_handler.OpenAIRealtimeStreamHandler.start`` routes both + # ``openai_realtime`` and ``openai_realtime_2`` engines through the + # GA adapter (the v1-beta API is deprecated server-side), so the + # patch target must be the GA adapter class. + with patch( + "getpatter.providers.openai_realtime_2.OpenAIRealtime2Adapter", + return_value=mock_adapter, + ): from getpatter.telephony.twilio import twilio_stream_bridge try: @@ -528,15 +526,22 @@ async def test_guardrail_does_not_trigger_on_clean_response(): system_prompt="test", provider="openai_realtime", guardrails=[ - {"name": "no-bad", "blocked_terms": ["blocked_word"], "check": None, "replacement": "..."} + { + "name": "no-bad", + "blocked_terms": ["blocked_word"], + "check": None, + "replacement": "...", + } ], ) - start_payload = json.dumps({ - "event": "start", - "streamSid": "SID_clean", - "start": {"callSid": "CA_clean", "customParameters": {}}, - }) + start_payload = json.dumps( + { + "event": "start", + "streamSid": "SID_clean", + "start": {"callSid": "CA_clean", "customParameters": {}}, + } + ) stop_payload = json.dumps({"event": "stop"}) messages = [start_payload, stop_payload] idx = 0 @@ -572,7 +577,10 @@ async def fake_events(): mock_adapter.receive_events = MagicMock(return_value=fake_events()) - with patch("getpatter.providers.openai_realtime.OpenAIRealtimeAdapter", return_value=mock_adapter): + with patch( + "getpatter.providers.openai_realtime.OpenAIRealtimeAdapter", + return_value=mock_adapter, + ): from getpatter.telephony.twilio import twilio_stream_bridge try: diff --git a/libraries/python/tests/unit/test_client_unit.py b/libraries/python/tests/unit/test_client_unit.py index 23f317da..65cc8536 100644 --- a/libraries/python/tests/unit/test_client_unit.py +++ b/libraries/python/tests/unit/test_client_unit.py @@ -365,6 +365,137 @@ async def test_disconnect_is_idempotent(self) -> None: await client.disconnect() # should not raise +# --------------------------------------------------------------------------- +# ready / tunnel_ready — serve-ready futures (parity with TS phone.ready) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestReadyFuture: + """phone.ready and phone.tunnel_ready — lazy futures that track serve() state.""" + + # --- lazy creation ------------------------------------------------------- + + async def test_ready_creates_future_on_first_access(self) -> None: + """Accessing phone.ready before serve() returns a pending Future.""" + import asyncio + + client = _local_phone() + fut = client.ready + assert isinstance(fut, asyncio.Future) + assert not fut.done() + + async def test_tunnel_ready_creates_future_on_first_access(self) -> None: + """Accessing phone.tunnel_ready before serve() returns a pending Future. + + Use webhook_url="" so there is no static pre-resolution — the tunnel + must call _resolve_tunnel_ready() before the future is done. + """ + import asyncio + + client = _local_phone(webhook_url="") + fut = client.tunnel_ready + assert isinstance(fut, asyncio.Future) + assert not fut.done() + + async def test_ready_returns_same_future_on_repeated_access(self) -> None: + """Repeated accesses return the same Future object (idempotent).""" + client = _local_phone() + assert client.ready is client.ready + + async def test_tunnel_ready_returns_same_future_on_repeated_access(self) -> None: + client = _local_phone(webhook_url="") + assert client.tunnel_ready is client.tunnel_ready + + # --- resolution ---------------------------------------------------------- + + async def test_ready_resolves_when_server_listening(self) -> None: + """_resolve_ready() fulfils phone.ready with the webhook hostname.""" + client = _local_phone() + fut = client.ready + assert not fut.done() + + client._resolve_ready("my-tunnel.trycloudflare.com") + + assert fut.done() + assert await fut == "my-tunnel.trycloudflare.com" + + async def test_tunnel_ready_resolves_when_tunnel_known(self) -> None: + client = _local_phone(webhook_url="") + fut = client.tunnel_ready + client._resolve_tunnel_ready("tunnel.example.com") + + assert fut.done() + assert await fut == "tunnel.example.com" + + async def test_ready_rejects_on_serve_failure(self) -> None: + """_reject_ready() causes phone.ready to raise the propagated error.""" + import asyncio + + client = _local_phone() + fut = client.ready + + err = RuntimeError("port already in use") + client._reject_ready(err) + + assert fut.done() + with pytest.raises(RuntimeError, match="port already in use"): + await asyncio.shield(fut) + + # --- idempotence (safe to call resolve/reject multiple times) ----------- + + async def test_resolve_ready_is_idempotent(self) -> None: + """Calling _resolve_ready() twice does not raise FutureDoneError.""" + client = _local_phone() + client._resolve_ready("host-a.com") + client._resolve_ready("host-b.com") # should be a no-op, not raise + assert await client.ready == "host-a.com" + + async def test_reject_ready_after_resolve_is_noop(self) -> None: + client = _local_phone() + client._resolve_ready("host.com") + client._reject_ready(RuntimeError("ignored")) # must not raise + assert await client.ready == "host.com" + + # --- static-webhook pre-resolution (no tunnel) -------------------------- + + async def test_tunnel_ready_pre_resolved_for_static_webhook(self) -> None: + """With an explicit webhookUrl, tunnel_ready is resolved immediately.""" + client = _local_phone(webhook_url="static.example.com") + fut = client.tunnel_ready + assert fut.done() + assert fut.result() == "static.example.com" + + # --- disconnect recreates the futures ----------------------------------- + + async def test_disconnect_recreates_ready_future(self) -> None: + """After disconnect(), phone.ready is a fresh pending Future. + + Mirrors the TS test: + 'recreates ready / tunnelReady so a follow-up serve() can resolve them' + """ + client = _local_phone() + before = client.ready + client._resolve_ready("host.com") + + await client.disconnect() + + after = client.ready + assert after is not before + assert not after.done() + + async def test_disconnect_recreates_tunnel_ready_future(self) -> None: + client = _local_phone(webhook_url="") + before = client.tunnel_ready + client._resolve_tunnel_ready("t.example.com") + + await client.disconnect() + + after = client.tunnel_ready + assert after is not before + assert not after.done() + + # --------------------------------------------------------------------------- # Module-level factories (guardrail, tool) # --------------------------------------------------------------------------- diff --git a/libraries/python/tests/unit/test_providers_io_unit.py b/libraries/python/tests/unit/test_providers_io_unit.py index 71ada0a3..166ff8da 100644 --- a/libraries/python/tests/unit/test_providers_io_unit.py +++ b/libraries/python/tests/unit/test_providers_io_unit.py @@ -80,6 +80,7 @@ async def _fake_ws_connect(mock_ws): def _ws_connect_side_effect(mock_ws): async def _connect(*a, **kw): return mock_ws + return _connect @@ -100,7 +101,10 @@ async def test_connect_sends_session_update(self) -> None: mock_ws = AsyncMock() mock_ws.recv.return_value = json.dumps({"type": "session.created"}) - with patch("getpatter.providers.openai_realtime.websockets.connect", side_effect=_ws_connect_side_effect(mock_ws)): + with patch( + "getpatter.providers.openai_realtime.websockets.connect", + side_effect=_ws_connect_side_effect(mock_ws), + ): await adapter.connect() assert adapter._running is True @@ -136,12 +140,21 @@ async def test_connect_honours_custom_silence_duration_ms(self) -> None: async def test_connect_with_tools(self) -> None: from getpatter.providers.openai_realtime import OpenAIRealtimeAdapter - tools = [{"name": "search", "description": "Search", "parameters": {"type": "object"}}] + tools = [ + { + "name": "search", + "description": "Search", + "parameters": {"type": "object"}, + } + ] adapter = OpenAIRealtimeAdapter(api_key="sk-test", tools=tools) mock_ws = AsyncMock() mock_ws.recv.return_value = json.dumps({"type": "session.created"}) - with patch("getpatter.providers.openai_realtime.websockets.connect", side_effect=_ws_connect_side_effect(mock_ws)): + with patch( + "getpatter.providers.openai_realtime.websockets.connect", + side_effect=_ws_connect_side_effect(mock_ws), + ): await adapter.connect() sent = json.loads(mock_ws.send.call_args[0][0]) @@ -152,11 +165,16 @@ async def test_connect_with_tools(self) -> None: async def test_connect_default_instructions(self) -> None: from getpatter.providers.openai_realtime import OpenAIRealtimeAdapter - adapter = OpenAIRealtimeAdapter(api_key="sk-test", instructions="", language="fr") + adapter = OpenAIRealtimeAdapter( + api_key="sk-test", instructions="", language="fr" + ) mock_ws = AsyncMock() mock_ws.recv.return_value = json.dumps({"type": "session.created"}) - with patch("getpatter.providers.openai_realtime.websockets.connect", side_effect=_ws_connect_side_effect(mock_ws)): + with patch( + "getpatter.providers.openai_realtime.websockets.connect", + side_effect=_ws_connect_side_effect(mock_ws), + ): await adapter.connect() sent = json.loads(mock_ws.send.call_args[0][0]) @@ -170,7 +188,10 @@ async def test_connect_raises_on_unexpected_first_message(self) -> None: mock_ws = AsyncMock() mock_ws.recv.return_value = json.dumps({"type": "error"}) - with patch("getpatter.providers.openai_realtime.websockets.connect", side_effect=_ws_connect_side_effect(mock_ws)): + with patch( + "getpatter.providers.openai_realtime.websockets.connect", + side_effect=_ws_connect_side_effect(mock_ws), + ): with pytest.raises(RuntimeError, match="Expected session.created"): await adapter.connect() @@ -195,9 +216,30 @@ async def test_cancel_response_sends_cancel(self) -> None: adapter = OpenAIRealtimeAdapter(api_key="sk-test") adapter._ws = AsyncMock() + # ``cancel_response`` is now a no-op when no item is in flight + # (avoids the ``response_cancel_not_active`` log spam every phantom + # VAD ``speech_started`` would otherwise trigger). Simulate an + # in-flight assistant item so the cancel path runs through. + adapter._current_response_item_id = "msg_test_001" await adapter.cancel_response() - sent = json.loads(adapter._ws.send.call_args[0][0]) - assert sent["type"] == "response.cancel" + # The last send must be ``response.cancel`` (preceded by an + # optional ``conversation.item.truncate`` when an item id is set). + last_sent = json.loads(adapter._ws.send.call_args_list[-1][0][0]) + assert last_sent["type"] == "response.cancel" + + @pytest.mark.asyncio + async def test_cancel_response_noop_when_no_item_in_flight(self) -> None: + """Regression: ``cancel_response`` must silently no-op when no + response item is in flight — eliminates the + ``response_cancel_not_active`` ERROR spam every phantom VAD + ``speech_started`` triggered before 0.6.2.""" + from getpatter.providers.openai_realtime import OpenAIRealtimeAdapter + + adapter = OpenAIRealtimeAdapter(api_key="sk-test") + adapter._ws = AsyncMock() + adapter._current_response_item_id = None + await adapter.cancel_response() + adapter._ws.send.assert_not_called() @pytest.mark.asyncio async def test_send_text_creates_item_and_triggers_response(self) -> None: @@ -248,7 +290,9 @@ async def test_receive_events_yields_transcript_output(self) -> None: from getpatter.providers.openai_realtime import OpenAIRealtimeAdapter adapter = OpenAIRealtimeAdapter(api_key="sk-test") - messages = [json.dumps({"type": "response.audio_transcript.delta", "delta": "Hello"})] + messages = [ + json.dumps({"type": "response.audio_transcript.delta", "delta": "Hello"}) + ] adapter._ws = _AsyncIterableWS(messages) events = [] @@ -261,7 +305,14 @@ async def test_receive_events_yields_transcript_input(self) -> None: from getpatter.providers.openai_realtime import OpenAIRealtimeAdapter adapter = OpenAIRealtimeAdapter(api_key="sk-test") - messages = [json.dumps({"type": "conversation.item.input_audio_transcription.completed", "transcript": "Hi"})] + messages = [ + json.dumps( + { + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "Hi", + } + ) + ] adapter._ws = _AsyncIterableWS(messages) events = [] @@ -291,10 +342,16 @@ async def test_receive_events_yields_function_call(self) -> None: from getpatter.providers.openai_realtime import OpenAIRealtimeAdapter adapter = OpenAIRealtimeAdapter(api_key="sk-test") - messages = [json.dumps({ - "type": "response.function_call_arguments.done", - "call_id": "fc1", "name": "search", "arguments": '{"q":"test"}', - })] + messages = [ + json.dumps( + { + "type": "response.function_call_arguments.done", + "call_id": "fc1", + "name": "search", + "arguments": '{"q":"test"}', + } + ) + ] adapter._ws = _AsyncIterableWS(messages) events = [] @@ -380,7 +437,10 @@ async def test_connect_with_agent_id(self) -> None: adapter = ElevenLabsConvAIAdapter(api_key="el-test", agent_id="agent_xyz") mock_ws = AsyncMock() - with patch("getpatter.providers.elevenlabs_convai.websockets.connect", side_effect=_ws_connect_side_effect(mock_ws)) as mc: + with patch( + "getpatter.providers.elevenlabs_convai.websockets.connect", + side_effect=_ws_connect_side_effect(mock_ws), + ) as mc: await adapter.connect() call_url = mc.call_args[0][0] @@ -390,23 +450,36 @@ async def test_connect_with_agent_id(self) -> None: async def test_connect_with_first_message(self) -> None: from getpatter.providers.elevenlabs_convai import ElevenLabsConvAIAdapter - adapter = ElevenLabsConvAIAdapter(api_key="el-test", agent_id="agent-test", first_message="Hi there!") + adapter = ElevenLabsConvAIAdapter( + api_key="el-test", agent_id="agent-test", first_message="Hi there!" + ) mock_ws = AsyncMock() - with patch("getpatter.providers.elevenlabs_convai.websockets.connect", side_effect=_ws_connect_side_effect(mock_ws)): + with patch( + "getpatter.providers.elevenlabs_convai.websockets.connect", + side_effect=_ws_connect_side_effect(mock_ws), + ): await adapter.connect() sent = json.loads(mock_ws.send.call_args[0][0]) - assert sent["conversation_config_override"]["agent"]["first_message"] == "Hi there!" + assert ( + sent["conversation_config_override"]["agent"]["first_message"] + == "Hi there!" + ) @pytest.mark.asyncio async def test_connect_without_first_message(self) -> None: from getpatter.providers.elevenlabs_convai import ElevenLabsConvAIAdapter - adapter = ElevenLabsConvAIAdapter(api_key="el-test", agent_id="agent-test", first_message="") + adapter = ElevenLabsConvAIAdapter( + api_key="el-test", agent_id="agent-test", first_message="" + ) mock_ws = AsyncMock() - with patch("getpatter.providers.elevenlabs_convai.websockets.connect", side_effect=_ws_connect_side_effect(mock_ws)): + with patch( + "getpatter.providers.elevenlabs_convai.websockets.connect", + side_effect=_ws_connect_side_effect(mock_ws), + ): await adapter.connect() sent = json.loads(mock_ws.send.call_args[0][0]) @@ -470,11 +543,13 @@ async def test_receive_events_yields_transcripts(self) -> None: adapter = ElevenLabsConvAIAdapter(api_key="el-test", agent_id="agent-test") await self._prime_adapter_with_ws( adapter, - _AsyncIterableWS([ - json.dumps({"type": "user_transcript", "text": "Hi"}), - json.dumps({"type": "agent_response", "text": "Hello"}), - json.dumps({"type": "interruption"}), - ]), + _AsyncIterableWS( + [ + json.dumps({"type": "user_transcript", "text": "Hi"}), + json.dumps({"type": "agent_response", "text": "Hello"}), + json.dumps({"type": "interruption"}), + ] + ), ) events = [] @@ -595,9 +670,13 @@ async def test_ping_triggers_pong(self) -> None: adapter = ElevenLabsConvAIAdapter(api_key="el-test", agent_id="agent-test") mock_ws = AsyncMock() # Iterable messages include a ping. - mock_ws.__aiter__ = lambda self: _AsyncIterHelper([ - json.dumps({"type": "ping", "ping_event": {"event_id": "xyz", "ping_ms": 0}}), - ]) + mock_ws.__aiter__ = lambda self: _AsyncIterHelper( + [ + json.dumps( + {"type": "ping", "ping_event": {"event_id": "xyz", "ping_ms": 0}} + ), + ] + ) adapter._ws = mock_ws adapter._events = asyncio.Queue() adapter._reader_task = asyncio.create_task(adapter._read_loop()) @@ -830,7 +909,10 @@ async def test_connect(self) -> None: stt = DeepgramSTT(api_key="dg-test") mock_ws = AsyncMock() - with patch("getpatter.providers.deepgram_stt.websockets.connect", side_effect=_ws_connect_side_effect(mock_ws)) as mc: + with patch( + "getpatter.providers.deepgram_stt.websockets.connect", + side_effect=_ws_connect_side_effect(mock_ws), + ) as mc: await stt.connect() assert stt._ws is mock_ws @@ -862,12 +944,18 @@ async def test_receive_transcripts_yields_results(self) -> None: from getpatter.providers.deepgram_stt import DeepgramSTT stt = DeepgramSTT(api_key="dg-test") - messages = [json.dumps({ - "type": "Results", - "is_final": True, - "speech_final": True, - "channel": {"alternatives": [{"transcript": "Hello", "confidence": 0.9}]}, - })] + messages = [ + json.dumps( + { + "type": "Results", + "is_final": True, + "speech_final": True, + "channel": { + "alternatives": [{"transcript": "Hello", "confidence": 0.9}] + }, + } + ) + ] stt._ws = _AsyncIterableWS(messages) transcripts = [] @@ -979,7 +1067,7 @@ def test_resample_24k_to_16k_basic(self) -> None: samples = [100, 200, 300, 400, 500, 600] audio = struct.pack(f"<{len(samples)}h", *samples) result = OpenAITTS._resample_24k_to_16k(audio) - out_samples = struct.unpack(f"<{len(result)//2}h", result) + out_samples = struct.unpack(f"<{len(result) // 2}h", result) assert len(out_samples) == 4 def test_resample_24k_to_16k_empty(self) -> None: @@ -1092,7 +1180,9 @@ async def test_initiate_call(self) -> None: adapter._client = AsyncMock() adapter._client.post.return_value = mock_resp - call_id = await adapter.initiate_call("+15551111111", "+15552222222", "wss://stream.example.com") + call_id = await adapter.initiate_call( + "+15551111111", "+15552222222", "wss://stream.example.com" + ) assert call_id == "v3:new-id" @pytest.mark.asyncio @@ -1386,11 +1476,15 @@ async def test_provision_number(self) -> None: mock_number = MagicMock() mock_number.phone_number = "+15559999999" adapter._twilio_client = MagicMock() - adapter._twilio_client.available_phone_numbers.return_value.local.list.return_value = [mock_number] + adapter._twilio_client.available_phone_numbers.return_value.local.list.return_value = [ + mock_number + ] mock_purchased = MagicMock() mock_purchased.phone_number = "+15559999999" - adapter._twilio_client.incoming_phone_numbers.create.return_value = mock_purchased + adapter._twilio_client.incoming_phone_numbers.create.return_value = ( + mock_purchased + ) number = await adapter.provision_number("US") assert number == "+15559999999" @@ -1416,7 +1510,9 @@ async def test_configure_number(self) -> None: adapter._twilio_client.incoming_phone_numbers.list.return_value = [mock_num] await adapter.configure_number("+15551111111", "https://example.com/webhook") - mock_num.update.assert_called_once_with(voice_url="https://example.com/webhook", voice_method="POST") + mock_num.update.assert_called_once_with( + voice_url="https://example.com/webhook", voice_method="POST" + ) @pytest.mark.asyncio async def test_configure_number_not_found(self) -> None: @@ -1427,7 +1523,9 @@ async def test_configure_number_not_found(self) -> None: adapter._twilio_client.incoming_phone_numbers.list.return_value = [] with pytest.raises(ValueError, match="not found"): - await adapter.configure_number("+15551111111", "https://example.com/webhook") + await adapter.configure_number( + "+15551111111", "https://example.com/webhook" + ) @pytest.mark.asyncio async def test_initiate_call(self) -> None: @@ -1439,7 +1537,9 @@ async def test_initiate_call(self) -> None: adapter._twilio_client = MagicMock() adapter._twilio_client.calls.create.return_value = mock_call - sid = await adapter.initiate_call("+15551111111", "+15552222222", "wss://stream.example.com") + sid = await adapter.initiate_call( + "+15551111111", "+15552222222", "wss://stream.example.com" + ) assert sid == "CA_test_call_sid" @pytest.mark.asyncio @@ -1453,7 +1553,9 @@ async def test_initiate_call_with_extra_params(self) -> None: adapter._twilio_client.calls.create.return_value = mock_call sid = await adapter.initiate_call( - "+15551111111", "+15552222222", "wss://stream.example.com", + "+15551111111", + "+15552222222", + "wss://stream.example.com", extra_params={"machine_detection": "Enable"}, ) assert sid == "CA_test_call_sid" @@ -1468,7 +1570,9 @@ async def test_end_call(self) -> None: adapter._twilio_client = MagicMock() await adapter.end_call("CA_test_call_sid") - adapter._twilio_client.calls.return_value.update.assert_called_once_with(status="completed") + adapter._twilio_client.calls.return_value.update.assert_called_once_with( + status="completed" + ) def test_generate_stream_twiml(self) -> None: from getpatter.providers.twilio_adapter import TwilioAdapter diff --git a/libraries/python/tests/unit/test_providers_unit.py b/libraries/python/tests/unit/test_providers_unit.py index a9c6a411..5b357882 100644 --- a/libraries/python/tests/unit/test_providers_unit.py +++ b/libraries/python/tests/unit/test_providers_unit.py @@ -393,8 +393,12 @@ def __init__(self, **kwargs: object) -> None: async def connect(self) -> None: return None + # ``OpenAIRealtimeStreamHandler.start`` routes both + # ``openai_realtime`` and ``openai_realtime_2`` engines through the + # GA adapter (the v1-beta endpoint is deprecated server-side), + # so the patch target must be the GA adapter class. with patch( - "getpatter.providers.openai_realtime.OpenAIRealtimeAdapter", + "getpatter.providers.openai_realtime_2.OpenAIRealtime2Adapter", _FakeAdapter, ): await handler.start() diff --git a/libraries/python/tests/unit/test_stream_handler_unit.py b/libraries/python/tests/unit/test_stream_handler_unit.py index 92f9a533..3f4aa01a 100644 --- a/libraries/python/tests/unit/test_stream_handler_unit.py +++ b/libraries/python/tests/unit/test_stream_handler_unit.py @@ -519,13 +519,17 @@ async def test_barge_in_fires_after_warmup_window(self) -> None: "barge-in must fire normally after the AEC warmup gate elapses" ) - async def test_barge_in_fires_at_400ms_when_aec_off(self) -> None: - """The bug fix: on PSTN deployments AEC is OFF and the gate - collapses to 0.1 s anti-flicker. A user saying "stop" 400 ms - into the agent's turn must cancel the agent — pre-fix this was - silently suppressed by the hardcoded 1.0 s gate. + async def test_barge_in_fires_at_600ms_when_aec_off(self) -> None: + """On PSTN deployments AEC is OFF and the gate is + MIN_AGENT_SPEAKING_S_BEFORE_BARGE_IN_NO_AEC (0.5 s, bumped from + 0.1 s in 0.6.2 acceptance to filter phantom speech_start on first + inbound frame). A user saying "stop" 600 ms into the agent's turn + must cancel the agent — just past the 0.5 s gate. """ - from getpatter.stream_handler import PipelineStreamHandler + from getpatter.stream_handler import ( + MIN_AGENT_SPEAKING_S_BEFORE_BARGE_IN_NO_AEC, + PipelineStreamHandler, + ) from getpatter.providers.base import Transcript import time @@ -536,17 +540,18 @@ async def test_barge_in_fires_at_400ms_when_aec_off(self) -> None: handler.audio_sender = MagicMock() handler.audio_sender.send_clear = AsyncMock() handler._llm_cancel_event = asyncio.Event() - # AEC OFF (PSTN default) — gate is 0.25 s. + # AEC OFF (PSTN default) — gate is 0.5 s. handler._aec = None - handler._speaking_started_at = time.time() - 0.4 - handler._first_audio_sent_at = time.time() - 0.4 + past_gate = MIN_AGENT_SPEAKING_S_BEFORE_BARGE_IN_NO_AEC + 0.1 + handler._speaking_started_at = time.time() - past_gate + handler._first_audio_sent_at = time.time() - past_gate await handler._handle_barge_in( Transcript(text="stop", is_final=True, speech_final=True) ) assert handler._llm_cancel_event.is_set(), ( - "barge-in must fire on PSTN at 400 ms — past the 0.1 s anti-flicker gate" + "barge-in must fire on PSTN at 600 ms — past the 0.5 s gate" ) async def test_barge_in_suppressed_within_anti_flicker_when_aec_off( diff --git a/libraries/python/tests/unit/test_twilio_adapter_snake_case_kwargs.py b/libraries/python/tests/unit/test_twilio_adapter_snake_case_kwargs.py new file mode 100644 index 00000000..2c070188 --- /dev/null +++ b/libraries/python/tests/unit/test_twilio_adapter_snake_case_kwargs.py @@ -0,0 +1,321 @@ +"""Regression test for the PascalCase → snake_case Twilio kwarg bug. + +The ``twilio-python`` SDK's ``client.calls.create(**kwargs)`` accepts +**snake_case** keyword arguments and translates them internally to the +PascalCase form Twilio's REST wire protocol expects. Passing +PascalCase keys (``StatusCallback``, ``MachineDetection``, ``Timeout``) +directly raises ``TypeError: unexpected keyword argument`` and crashes +every outbound call. + +This file locks in two behaviours: + +1. ``getpatter.client.Patter.call`` builds ``extra_params`` for the + Twilio adapter using snake_case keys only (fix at source). +2. ``TwilioAdapter.initiate_call`` invokes the underlying + ``calls.create`` with snake_case kwargs even if a caller passes + PascalCase (defensive normalisation in the adapter). + +Both behaviours are exercised end-to-end against the real adapter +with a real ``TwilioClient`` whose ``.calls.create`` raises ``TypeError`` +on PascalCase — mirroring the production failure mode. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from getpatter.client import Patter +from getpatter.local_config import LocalConfig +from getpatter.providers.twilio_adapter import TwilioAdapter, _to_snake_case + +from tests.conftest import make_agent + + +# --------------------------------------------------------------------------- +# Helper: a ``calls.create`` stand-in that enforces snake_case kwargs. +# --------------------------------------------------------------------------- + +# These are the Twilio params the SDK explicitly accepts in snake_case. +# A real ``twilio.rest.Client.calls.create(...)`` has a typed signature +# that rejects anything else — we replicate that contract here so the +# test fails the same way production does. +_ACCEPTED_SNAKE_KWARGS = frozenset( + { + "to", + "from_", + "twiml", + "url", + "method", + "fallback_url", + "status_callback", + "status_callback_event", + "status_callback_method", + "send_digits", + "timeout", + "record", + "recording_channels", + "recording_status_callback", + "machine_detection", + "machine_detection_timeout", + "machine_detection_speech_threshold", + "machine_detection_speech_end_threshold", + "machine_detection_silence_timeout", + "async_amd", + "async_amd_status_callback", + "async_amd_status_callback_method", + "byoc", + "trunk_sid", + } +) + + +def _strict_create(**kwargs): + """Drop-in for ``twilio.rest.Client.calls.create`` that rejects + PascalCase keys exactly the way the real SDK does.""" + bad = [k for k in kwargs if k not in _ACCEPTED_SNAKE_KWARGS] + if bad: + raise TypeError(f"calls.create() got an unexpected keyword argument {bad[0]!r}") + resp = MagicMock() + resp.sid = "CA" + "f" * 32 + return resp + + +# --------------------------------------------------------------------------- +# Unit: the snake_case helper. +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestToSnakeCase: + def test_pascal_to_snake(self) -> None: + assert _to_snake_case("StatusCallback") == "status_callback" + assert _to_snake_case("AsyncAmdStatusCallback") == "async_amd_status_callback" + assert _to_snake_case("MachineDetection") == "machine_detection" + assert _to_snake_case("Timeout") == "timeout" + + def test_snake_passthrough(self) -> None: + assert _to_snake_case("timeout") == "timeout" + assert _to_snake_case("status_callback_event") == "status_callback_event" + + def test_camel_to_snake(self) -> None: + assert _to_snake_case("asyncAmd") == "async_amd" + + +# --------------------------------------------------------------------------- +# Adapter-level: TwilioAdapter.initiate_call invokes calls.create with +# snake_case kwargs ONLY. The strict stub raises TypeError otherwise — +# making this an authentic test that would catch the production bug. +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestAdapterPassesSnakeCaseKwargs: + @pytest.mark.asyncio + async def test_initiate_call_with_snake_case_extra_params(self) -> None: + # ``twilio.rest.Client.calls`` is a read-only property — we + # cannot patch it on a real instance. Replace the constructor + # so the adapter wraps a MagicMock whose ``calls.create`` is + # the strict validator. Everything else (TwiML construction, + # ``_run_sync`` threading, kwarg normalisation) runs the actual + # production code path. + with patch( + "getpatter.providers.twilio_adapter.TwilioClient" + ) as MockTwilioClient: + client_instance = MagicMock() + client_instance.calls = MagicMock() + client_instance.calls.create = _strict_create + MockTwilioClient.return_value = client_instance + + adapter = TwilioAdapter( + account_sid="ACtest000000000000000000000000000", + auth_token="tok_test", + ) + + sid = await adapter.initiate_call( + "+15551234567", + "+15559876543", + "wss://test.ngrok.io/ws/stream/outbound", + extra_params={ + "timeout": 25, + "machine_detection": "DetectMessageEnd", + "async_amd": "true", + "async_amd_status_callback": "https://test.ngrok.io/webhooks/twilio/amd", + "status_callback": "https://test.ngrok.io/webhooks/twilio/status", + "status_callback_method": "POST", + "status_callback_event": [ + "initiated", + "ringing", + "answered", + "completed", + ], + }, + ) + assert sid == "CA" + "f" * 32 + + @pytest.mark.asyncio + async def test_initiate_call_translates_pascal_case_defensively(self) -> None: + """Belt-and-braces: even if a future caller forgets and passes + PascalCase, the adapter must normalise before invoking the SDK. + Without the fix this raises ``TypeError`` (production crash).""" + captured: dict = {} + + def _capture(**kwargs): + # Re-use the strict validator so any leakage explodes here. + _strict_create(**kwargs) + captured.update(kwargs) + resp = MagicMock() + resp.sid = "CA" + "a" * 32 + return resp + + with patch( + "getpatter.providers.twilio_adapter.TwilioClient" + ) as MockTwilioClient: + client_instance = MagicMock() + client_instance.calls = MagicMock() + client_instance.calls.create = _capture + MockTwilioClient.return_value = client_instance + + adapter = TwilioAdapter( + account_sid="ACtest000000000000000000000000000", + auth_token="tok_test", + ) + + await adapter.initiate_call( + "+15551234567", + "+15559876543", + "wss://test.ngrok.io/ws/stream/outbound", + extra_params={ + "Timeout": 30, + "MachineDetection": "DetectMessageEnd", + "StatusCallback": "https://test.ngrok.io/webhooks/twilio/status", + }, + ) + + # Adapter must have rewritten all three to snake_case before + # the SDK call — and the strict validator must have accepted them. + assert captured["timeout"] == 30 + assert captured["machine_detection"] == "DetectMessageEnd" + assert ( + captured["status_callback"] + == "https://test.ngrok.io/webhooks/twilio/status" + ) + # PascalCase keys must NOT survive into the SDK call. + assert "Timeout" not in captured + assert "MachineDetection" not in captured + assert "StatusCallback" not in captured + + +# --------------------------------------------------------------------------- +# Client-level: Patter.call() routes through the adapter with snake_case +# keys. Hooking the adapter to the strict stub asserts the entire path is +# wire-correct — not just the dict shape. +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestPatterCallEndToEnd: + @pytest.mark.asyncio + async def test_outbound_call_does_not_raise_typeerror(self) -> None: + """Reproduce the user-reported zenn.dev bug: an outbound call with + machine_detection + ring_timeout must NOT raise ``TypeError`` from + twilio-python's ``calls.create``. Pre-fix, this test fails.""" + cfg = LocalConfig( + telephony_provider="twilio", + twilio_sid="ACtest000000000000000000000000000", + twilio_token="tok_test", + openai_key="sk-test", + webhook_url="test.ngrok.io", + phone_number="+15551234567", + ) + phone = Patter.__new__(Patter) + phone._local_config = cfg + phone._server = None + + # Patch the TwilioClient constructor so the adapter wraps a real + # TwilioAdapter instance but the underlying SDK is replaced by + # our strict validator. Everything else (URL/TwiML construction, + # ring_timeout propagation, status callback wiring, AMD params) + # runs the actual production code path. + captured: dict = {} + + def _capture(**kwargs): + _strict_create(**kwargs) + captured.update(kwargs) + resp = MagicMock() + resp.sid = "CA" + "b" * 32 + return resp + + with patch( + "getpatter.providers.twilio_adapter.TwilioClient" + ) as MockTwilioClient: + client_instance = MagicMock() + client_instance.calls = MagicMock() + client_instance.calls.create = _capture + MockTwilioClient.return_value = client_instance + + await phone.call( + to="+15559876543", + agent=make_agent(), + machine_detection=True, + ring_timeout=30, + ) + + # All the params Twilio cares about landed under snake_case keys. + assert captured["to"] == "+15559876543" + assert captured["from_"] == "+15551234567" + assert captured["timeout"] == 30 + assert captured["machine_detection"] == "DetectMessageEnd" + assert captured["async_amd"] == "true" + assert "async_amd_status_callback" in captured + assert ( + captured["status_callback"] + == "https://test.ngrok.io/webhooks/twilio/status" + ) + assert captured["status_callback_method"] == "POST" + assert "ringing" in captured["status_callback_event"] + assert "completed" in captured["status_callback_event"] + + @pytest.mark.asyncio + async def test_outbound_call_without_amd_still_snake_case(self) -> None: + """``machine_detection=False`` must still produce a snake_case + StatusCallback wiring — the dashboard relies on it.""" + cfg = LocalConfig( + telephony_provider="twilio", + twilio_sid="ACtest000000000000000000000000000", + twilio_token="tok_test", + openai_key="sk-test", + webhook_url="test.ngrok.io", + phone_number="+15551234567", + ) + phone = Patter.__new__(Patter) + phone._local_config = cfg + phone._server = None + + captured: dict = {} + + def _capture(**kwargs): + _strict_create(**kwargs) + captured.update(kwargs) + resp = MagicMock() + resp.sid = "CA" + "c" * 32 + return resp + + with patch( + "getpatter.providers.twilio_adapter.TwilioClient" + ) as MockTwilioClient: + client_instance = MagicMock() + client_instance.calls = MagicMock() + client_instance.calls.create = _capture + MockTwilioClient.return_value = client_instance + + await phone.call( + to="+15559876543", + agent=make_agent(), + machine_detection=False, + ) + + assert "machine_detection" not in captured + assert "async_amd" not in captured + assert captured["status_callback"].endswith("/webhooks/twilio/status") diff --git a/libraries/python/tests/unit/test_twilio_status_and_ring_timeout.py b/libraries/python/tests/unit/test_twilio_status_and_ring_timeout.py index 51e0ea85..209f04e0 100644 --- a/libraries/python/tests/unit/test_twilio_status_and_ring_timeout.py +++ b/libraries/python/tests/unit/test_twilio_status_and_ring_timeout.py @@ -10,7 +10,8 @@ in-memory metrics store. 2. **IMP2** — callers may set ``ring_timeout`` on ``Patter.call()`` to control how long the phone rings before the carrier gives up. It - must land as ``Timeout`` on Twilio's REST payload and ``timeout_secs`` + must land as ``timeout`` on the twilio-python kwarg payload + (translated to Twilio's ``Timeout`` REST param) and as ``timeout_secs`` on Telnyx's — the old code silently dropped it. The tests exercise the endpoint directly via the ASGI layer, and exercise @@ -254,9 +255,7 @@ async def test_twilio_ring_timeout_becomes_timeout_param(self) -> None: "getpatter.providers.twilio_adapter.TwilioAdapter" ) as mock_adapter_cls: mock_adapter = mock_adapter_cls.return_value - mock_adapter.initiate_call = AsyncMock( - return_value="CA" + "9" * 32 - ) + mock_adapter.initiate_call = AsyncMock(return_value="CA" + "9" * 32) await phone.call( to="+15559876543", @@ -265,10 +264,8 @@ async def test_twilio_ring_timeout_becomes_timeout_param(self) -> None: ) mock_adapter.initiate_call.assert_awaited_once() - extra_params = mock_adapter.initiate_call.await_args.kwargs[ - "extra_params" - ] - assert extra_params["Timeout"] == 45 + extra_params = mock_adapter.initiate_call.await_args.kwargs["extra_params"] + assert extra_params["timeout"] == 45 @pytest.mark.asyncio async def test_twilio_ring_timeout_default_is_25(self) -> None: @@ -292,16 +289,12 @@ async def test_twilio_ring_timeout_default_is_25(self) -> None: "getpatter.providers.twilio_adapter.TwilioAdapter" ) as mock_adapter_cls: mock_adapter = mock_adapter_cls.return_value - mock_adapter.initiate_call = AsyncMock( - return_value="CA" + "8" * 32 - ) + mock_adapter.initiate_call = AsyncMock(return_value="CA" + "8" * 32) await phone.call(to="+15559876543", agent=make_agent()) - extra_params = mock_adapter.initiate_call.await_args.kwargs[ - "extra_params" - ] - assert extra_params["Timeout"] == 25 + extra_params = mock_adapter.initiate_call.await_args.kwargs["extra_params"] + assert extra_params["timeout"] == 25 @pytest.mark.asyncio async def test_twilio_ring_timeout_omitted_when_none(self) -> None: @@ -324,18 +317,16 @@ async def test_twilio_ring_timeout_omitted_when_none(self) -> None: "getpatter.providers.twilio_adapter.TwilioAdapter" ) as mock_adapter_cls: mock_adapter = mock_adapter_cls.return_value - mock_adapter.initiate_call = AsyncMock( - return_value="CA" + "8" * 32 - ) + mock_adapter.initiate_call = AsyncMock(return_value="CA" + "8" * 32) await phone.call( - to="+15559876543", agent=make_agent(), ring_timeout=None, + to="+15559876543", + agent=make_agent(), + ring_timeout=None, ) - extra_params = mock_adapter.initiate_call.await_args.kwargs[ - "extra_params" - ] - assert "Timeout" not in extra_params + extra_params = mock_adapter.initiate_call.await_args.kwargs["extra_params"] + assert "timeout" not in extra_params @pytest.mark.asyncio async def test_twilio_statuscallback_always_registered(self) -> None: @@ -359,24 +350,23 @@ async def test_twilio_statuscallback_always_registered(self) -> None: "getpatter.providers.twilio_adapter.TwilioAdapter" ) as mock_adapter_cls: mock_adapter = mock_adapter_cls.return_value - mock_adapter.initiate_call = AsyncMock( - return_value="CA" + "7" * 32 - ) + mock_adapter.initiate_call = AsyncMock(return_value="CA" + "7" * 32) await phone.call(to="+15559876543", agent=make_agent()) - extra = mock_adapter.initiate_call.await_args.kwargs[ - "extra_params" - ] + extra = mock_adapter.initiate_call.await_args.kwargs["extra_params"] + # Keys are snake_case — the twilio-python SDK's + # ``calls.create(**kwargs)`` rejects PascalCase with + # ``TypeError: unexpected keyword argument``. assert ( - extra["StatusCallback"] + extra["status_callback"] == "https://test.ngrok.io/webhooks/twilio/status" ) - assert extra["StatusCallbackMethod"] == "POST" - # Events we care about for BUG #06. Now passed as a list under + assert extra["status_callback_method"] == "POST" + # Events we care about for BUG #06. Passed as a list under # the snake_case key the twilio-python SDK expects (see # 2026-04-29 fix for Twilio notification 21626). - events = extra.get("status_callback_event") or extra.get("StatusCallbackEvent") + events = extra["status_callback_event"] assert "ringing" in events assert "completed" in events diff --git a/libraries/typescript/package.json b/libraries/typescript/package.json index 047b8aad..5dc27006 100644 --- a/libraries/typescript/package.json +++ b/libraries/typescript/package.json @@ -1,6 +1,6 @@ { "name": "getpatter", - "version": "0.6.1", + "version": "0.6.2", "description": "Open-source voice AI SDK — connect any AI agent to real phone calls in 4 lines of code", "license": "MIT", "author": { diff --git a/libraries/typescript/src/cli.ts b/libraries/typescript/src/cli.ts index 8cb332a3..fd837799 100644 --- a/libraries/typescript/src/cli.ts +++ b/libraries/typescript/src/cli.ts @@ -76,7 +76,13 @@ async function main(): Promise { res.json({ status: 'ok', mode: 'dashboard' }); }); - // Ingest endpoint — SDK POSTs completed call data here for live updates + // Ingest endpoint — SDK POSTs call lifecycle events here so a + // standalone dashboard surfaces them live. Three event kinds: + // * status="initiated" — outbound dial handed off to carrier, + // callee hasn't picked up yet. Surfaces the row immediately so + // the user sees the attempt during ringing. + // * default (no status) — call_start, media stream began. + // * ended_at present — call_end, final metrics + transcript. app.post('/api/dashboard/ingest', (req, res) => { const data = req.body as Record; const callId = (data.call_id as string) || ''; @@ -84,6 +90,12 @@ async function main(): Promise { res.json({ ok: false, error: 'missing call_id' }); return; } + const status = data.status as string | undefined; + if (status === 'initiated') { + store.recordCallInitiated(data); + res.json({ ok: true, call_id: callId, event: 'initiated' }); + return; + } store.recordCallStart(data); if (data.ended_at) { store.recordCallEnd(data, (data.metrics as Record) ?? null); diff --git a/libraries/typescript/src/client.ts b/libraries/typescript/src/client.ts index 2138df15..87288120 100644 --- a/libraries/typescript/src/client.ts +++ b/libraries/typescript/src/client.ts @@ -124,14 +124,27 @@ function resolvePersistRoot(persist: boolean | string | undefined): string | nul if (persist === false) return null; if (persist === true) return resolveLogRoot('auto'); if (typeof persist === 'string') return resolveLogRoot(persist); - return resolveLogRoot(); + // Changed from the prior opt-in behaviour on 2026-05-21: the dashboard's + // hydrate path requires on-disk records to survive process restarts, so + // persistence now defaults to ON when `persist` is omitted. Set + // `persist: false` to keep the old ephemeral-RAM-only behaviour. + const envRoot = resolveLogRoot(); + if (envRoot !== null) return envRoot; + return resolveLogRoot('auto'); } /** Close every parked socket inside a ``ParkedProviderConnections`` slot. */ function closeParkedConnections(slot: ParkedProviderConnections): void { if (slot.stt) { try { slot.stt.close(); } catch { /* ignore */ } } if (slot.tts) { try { slot.tts.ws.close(); } catch { /* ignore */ } } - if (slot.openaiRealtime) { try { slot.openaiRealtime.close(); } catch { /* ignore */ } } + if (slot.openaiRealtime) { + const wsAny = slot.openaiRealtime as unknown as { _parkedKeepalive?: NodeJS.Timeout }; + if (wsAny._parkedKeepalive) { + clearInterval(wsAny._parkedKeepalive); + delete wsAny._parkedKeepalive; + } + try { slot.openaiRealtime.close(); } catch { /* ignore */ } + } } /** Top-level SDK entry point — wraps a carrier + embedded server + agent loop. */ @@ -520,6 +533,17 @@ export class Patter { validateAllToolSchemas(working.tools as ToolDefinition[]); } + // ``prewarmFirstMessage`` is opt-in (default false) — reverted from + // 2026-05-18's default-on attempt after the 0.6.2 acceptance run + // surfaced a phantom-barge-in interaction: prewarm bursts audio + // at pickup, the very first inbound carrier frame triggered Silero + // VAD speech_start, the firstMessage was cancelled mid-playback + // and the user heard a clipped (graffiante) fragment. Until the + // root cause (anchoring the barge-in gate on first-mark-echo + // rather than ``firstAudioSentAt = beginSpeaking time``) is fully + // addressed, default it off so most pipeline calls take the + // live-streaming path that the user is happy with. Opt in + // explicitly per agent when willing to pay the trade-off. return working; } @@ -861,7 +885,19 @@ export class Patter { const tts = agent.tts as { openParkedConnection?: () => Promise } | undefined; const sttOpen = typeof stt?.openParkedConnection === 'function' ? stt.openParkedConnection.bind(stt) : null; const ttsOpen = typeof tts?.openParkedConnection === 'function' ? tts.openParkedConnection.bind(tts) : null; - if (!sttOpen && !ttsOpen) return; + // Detect OpenAI Realtime agents (provider == 'openai_realtime' or + // 'openai_realtime_2'). The adapter isn't constructed yet — the + // per-call StreamHandler builds it at `start`. We instantiate a + // throw-away one here just long enough to call openParkedConnection + // and produce a primed WS, then store the WS in the slot. The live + // adapter (built per-call) adopts it via `adoptWebSocket`. Cast + // through `string` because the public ``AgentOptions.provider`` + // literal union doesn't yet enumerate ``openai_realtime_2`` (the + // GA engine carries it through internally). + const providerStr = (agent.provider as unknown as string | undefined) ?? ''; + const wantsRealtimePark = + providerStr === 'openai_realtime' || providerStr === 'openai_realtime_2'; + if (!sttOpen && !ttsOpen && !wantsRealtimePark) return; const slot: ParkedProviderConnections = {}; this.prewarmedConnections.set(callId, slot); @@ -905,6 +941,47 @@ export class Patter { } })()); } + if (wantsRealtimePark) { + tasks.push((async () => { + // Defer the import so users that don't use Realtime don't pay + // the load-time cost of the adapter + ws module. + const { OpenAIRealtime2Adapter } = await import('./providers/openai-realtime-2'); + const apiKey = process.env.OPENAI_API_KEY ?? ''; + if (!apiKey) { + getLogger().debug(`Park OpenAI Realtime skipped for ${callId}: no OPENAI_API_KEY`); + return; + } + try { + // Build a throw-away adapter just to call openParkedConnection. + // The session.update payload mirrors what the per-call + // StreamHandler would send so no second session.update is + // needed after adoption. The constructor signature is + // positional (inherited from OpenAIRealtimeAdapter). + const tmpAdapter = new OpenAIRealtime2Adapter( + apiKey, + (agent.model as string | undefined) ?? 'gpt-realtime-mini', + (agent.voice as string | undefined) ?? 'alloy', + (agent.systemPrompt as string | undefined) ?? '', + [], + // audioFormat — the GA adapter always emits audio/pcm@24000 + // internally regardless of this value, but it's a required + // positional param. Default to g711_ulaw (Twilio wire format). + undefined, + ); + const ws = await tmpAdapter.openParkedConnection(); + if (this.prewarmedConnections.get(callId) !== slot) { + try { ws.close(); } catch { /* ignore */ } + return; + } + slot.openaiRealtime = ws; + getLogger().info( + `[PREWARM] callId=${callId} provider=openai_realtime ms=${Date.now() - startedAt}`, + ); + } catch (err) { + getLogger().debug(`Park OpenAI Realtime failed for ${callId}: ${String(err)}`); + } + })()); + } const task = (async () => { await Promise.allSettled(tasks); @@ -992,6 +1069,7 @@ export class Patter { agent: AgentOptions, callId: string, ringTimeout: number | null | undefined, + carrier?: 'twilio' | 'telnyx', ): void { if (!agent.prewarmFirstMessage) return; // FIX #94 — Realtime / ConvAI never consume the cache. Refuse early @@ -1010,6 +1088,28 @@ export class Patter { if (!firstMessage || !tts) return; if (typeof tts.synthesizeStream !== 'function') return; + // Advise the TTS adapter of the telephony carrier BEFORE we trigger + // the synth so it can produce wire-native bytes (``ulaw_8000`` for + // Twilio, ``pcm_16000`` for Telnyx) — skipping the client-side + // resample + mulaw encode that produced audible artifacts on the + // prewarmed firstMessage during 0.6.2 acceptance. The hook is opt-in + // per-adapter; adapters that don't expose it (or that the user + // configured with an explicit outputFormat) keep their format. + if (carrier) { + const carrierAware = tts as unknown as { + setTelephonyCarrier?: (c: string) => void; + }; + if (typeof carrierAware.setTelephonyCarrier === 'function') { + try { + carrierAware.setTelephonyCarrier(carrier); + } catch (err) { + getLogger().debug( + `Prewarm TTS setTelephonyCarrier failed for ${callId}: ${String(err)}`, + ); + } + } + } + // FIX #96 — refuse to spawn when the cache (live entries + // in-flight synth tasks) would exceed the cap. Counting both // active entries AND pending tasks keeps the bound honest under @@ -1185,16 +1285,30 @@ export class Patter { } catch { /* non-fatal */ } - if (this.embeddedServer && telnyxCallId) { - this.embeddedServer.metricsStore.recordCallInitiated({ + if (telnyxCallId) { + const initiatedPayload = { call_id: telnyxCallId, caller: phoneNumber, callee: options.to, direction: 'outbound', - }); + status: 'initiated', + } as const; + if (this.embeddedServer) { + this.embeddedServer.metricsStore.recordCallInitiated(initiatedPayload); + } + // Relay to a standalone dashboard (running in a separate process) + // so it surfaces the dial attempt during ringing, not only when + // media arrives on pickup. Fire-and-forget — silent when no + // standalone dashboard is listening. + try { + const { notifyDashboard } = await import('./dashboard/persistence'); + notifyDashboard(initiatedPayload); + } catch { + /* ignore */ + } } if (telnyxCallId) { - this.spawnPrewarmFirstMessage(options.agent, telnyxCallId, effectiveRingTimeout); + this.spawnPrewarmFirstMessage(options.agent, telnyxCallId, effectiveRingTimeout, 'telnyx'); // Park provider WebSockets in parallel so the per-call // StreamHandler can adopt them at ``start`` instead of paying // the cold-handshake on first turn. Off when the user @@ -1280,23 +1394,33 @@ export class Patter { } catch { /* non-fatal — the statusCallback will register anyway */ } - if (this.embeddedServer && twilioCallSid) { - this.embeddedServer.metricsStore.recordCallInitiated({ + if (twilioCallSid) { + const initiatedPayload = { call_id: twilioCallSid, caller: phoneNumber, callee: options.to, direction: 'outbound', - }); - if (twilioNotificationsPath) { - getLogger().info( - `Outbound call ${twilioCallSid} placed. ` + - `Twilio notifications: https://api.twilio.com${twilioNotificationsPath} ` + - '(check here if the call drops with no audio).', - ); + status: 'initiated', + } as const; + if (this.embeddedServer) { + this.embeddedServer.metricsStore.recordCallInitiated(initiatedPayload); + if (twilioNotificationsPath) { + getLogger().info( + `Outbound call ${twilioCallSid} placed. ` + + `Twilio notifications: https://api.twilio.com${twilioNotificationsPath} ` + + '(check here if the call drops with no audio).', + ); + } + } + try { + const { notifyDashboard } = await import('./dashboard/persistence'); + notifyDashboard(initiatedPayload); + } catch { + /* ignore */ } } if (twilioCallSid) { - this.spawnPrewarmFirstMessage(options.agent, twilioCallSid, effectiveRingTimeout); + this.spawnPrewarmFirstMessage(options.agent, twilioCallSid, effectiveRingTimeout, 'twilio'); // Park provider WebSockets in parallel so the per-call // StreamHandler can adopt them at ``start`` instead of paying // the cold-handshake on first turn. Off when the user diff --git a/libraries/typescript/src/dashboard/store.ts b/libraries/typescript/src/dashboard/store.ts index f1be69a4..8ce5c9cc 100644 --- a/libraries/typescript/src/dashboard/store.ts +++ b/libraries/typescript/src/dashboard/store.ts @@ -15,6 +15,12 @@ import { EventEmitter } from 'events'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { getLogger } from '../logger'; +import { VERSION } from '../version'; + +/** Resolved SDK version (single source of truth: ``package.json``). */ +function sdkVersion(): string { + return VERSION; +} /** Snapshot of a call as held by the dashboard store. */ export interface CallRecord { @@ -482,6 +488,7 @@ export class MetricsStore extends EventEmitter { avg_latency_ms: 0, cost_breakdown: { stt: 0, tts: 0, llm: 0, telephony: 0 }, active_calls: this.activeCalls.size, + sdk_version: sdkVersion(), }; } @@ -529,6 +536,7 @@ export class MetricsStore extends EventEmitter { telephony: Math.round(costTel * 1e6) / 1e6, }, active_calls: this.activeCalls.size, + sdk_version: sdkVersion(), }; } diff --git a/libraries/typescript/src/dashboard/ui.html b/libraries/typescript/src/dashboard/ui.html index 50347d38..29a02214 100644 --- a/libraries/typescript/src/dashboard/ui.html +++ b/libraries/typescript/src/dashboard/ui.html @@ -15,7 +15,7 @@ href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" /> - +`+s.stack}return{value:e,source:t,stack:l,digest:null}}function ts(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function Us(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var $d=typeof WeakMap=="function"?WeakMap:Map;function Ja(e,t,n){n=be(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){cl||(cl=!0,Js=r),Us(e,t)},n}function qa(e,t,n){n=be(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){Us(e,t)}}var s=e.stateNode;return s!==null&&typeof s.componentDidCatch=="function"&&(n.callback=function(){Us(e,t),typeof r!="function"&&(yt===null?yt=new Set([this]):yt.add(this));var o=t.stack;this.componentDidCatch(t.value,{componentStack:o!==null?o:""})}),n}function Hi(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new $d;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=bd.bind(null,e,t,n),t.then(e,e))}function Bi(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function Wi(e,t,n,r,l){return e.mode&1?(e.flags|=65536,e.lanes=l,e):(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=be(-1,1),t.tag=2,vt(n,t,1))),n.lanes|=1),e)}var Vd=lt.ReactCurrentOwner,me=!1;function ue(e,t,n,r){t.child=e===null?Ea(t,null,n,r):pn(t,e.child,n,r)}function Qi(e,t,n,r,l){n=n.render;var s=t.ref;return un(t,l),r=Io(e,t,n,r,s,l),n=Ao(),e!==null&&!me?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,rt(e,t,l)):(B&&n&&Co(t),t.flags|=1,ue(e,t,r,l),t.child)}function Ki(e,t,n,r,l){if(e===null){var s=n.type;return typeof s=="function"&&!Ko(s)&&s.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=s,ba(e,t,s,r,l)):(e=Br(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(s=e.child,!(e.lanes&l)){var o=s.memoizedProps;if(n=n.compare,n=n!==null?n:Gn,n(o,r)&&e.ref===t.ref)return rt(e,t,l)}return t.flags|=1,e=wt(s,r),e.ref=t.ref,e.return=t,t.child=e}function ba(e,t,n,r,l){if(e!==null){var s=e.memoizedProps;if(Gn(s,r)&&e.ref===t.ref)if(me=!1,t.pendingProps=r=s,(e.lanes&l)!==0)e.flags&131072&&(me=!0);else return t.lanes=e.lanes,rt(e,t,l)}return Hs(e,t,n,r,l)}function ec(e,t,n){var r=t.pendingProps,l=r.children,s=e!==null?e.memoizedState:null;if(r.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},F(nn,we),we|=n;else{if(!(n&1073741824))return e=s!==null?s.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,F(nn,we),we|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=s!==null?s.baseLanes:n,F(nn,we),we|=r}else s!==null?(r=s.baseLanes|n,t.memoizedState=null):r=n,F(nn,we),we|=r;return ue(e,t,l,n),t.child}function tc(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function Hs(e,t,n,r,l){var s=ye(n)?Dt:ie.current;return s=fn(t,s),un(t,l),n=Io(e,t,n,r,s,l),r=Ao(),e!==null&&!me?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,rt(e,t,l)):(B&&r&&Co(t),t.flags|=1,ue(e,t,n,l),t.child)}function Yi(e,t,n,r,l){if(ye(n)){var s=!0;el(t)}else s=!1;if(un(t,l),t.stateNode===null)Vr(e,t),Za(t,n,r),Vs(t,n,r,l),r=!0;else if(e===null){var o=t.stateNode,u=t.memoizedProps;o.props=u;var a=o.context,f=n.contextType;typeof f=="object"&&f!==null?f=Le(f):(f=ye(n)?Dt:ie.current,f=fn(t,f));var h=n.getDerivedStateFromProps,v=typeof h=="function"||typeof o.getSnapshotBeforeUpdate=="function";v||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(u!==r||a!==f)&&Ui(t,o,r,f),it=!1;var m=t.memoizedState;o.state=m,sl(t,r,o,l),a=t.memoizedState,u!==r||m!==a||ve.current||it?(typeof h=="function"&&($s(t,n,h,r),a=t.memoizedState),(u=it||Vi(t,n,u,r,m,a,f))?(v||typeof o.UNSAFE_componentWillMount!="function"&&typeof o.componentWillMount!="function"||(typeof o.componentWillMount=="function"&&o.componentWillMount(),typeof o.UNSAFE_componentWillMount=="function"&&o.UNSAFE_componentWillMount()),typeof o.componentDidMount=="function"&&(t.flags|=4194308)):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=a),o.props=r,o.state=a,o.context=f,r=u):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{o=t.stateNode,La(e,t),u=t.memoizedProps,f=t.type===t.elementType?u:ze(t.type,u),o.props=f,v=t.pendingProps,m=o.context,a=n.contextType,typeof a=="object"&&a!==null?a=Le(a):(a=ye(n)?Dt:ie.current,a=fn(t,a));var x=n.getDerivedStateFromProps;(h=typeof x=="function"||typeof o.getSnapshotBeforeUpdate=="function")||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(u!==v||m!==a)&&Ui(t,o,r,a),it=!1,m=t.memoizedState,o.state=m,sl(t,r,o,l);var w=t.memoizedState;u!==v||m!==w||ve.current||it?(typeof x=="function"&&($s(t,n,x,r),w=t.memoizedState),(f=it||Vi(t,n,f,r,m,w,a)||!1)?(h||typeof o.UNSAFE_componentWillUpdate!="function"&&typeof o.componentWillUpdate!="function"||(typeof o.componentWillUpdate=="function"&&o.componentWillUpdate(r,w,a),typeof o.UNSAFE_componentWillUpdate=="function"&&o.UNSAFE_componentWillUpdate(r,w,a)),typeof o.componentDidUpdate=="function"&&(t.flags|=4),typeof o.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof o.componentDidUpdate!="function"||u===e.memoizedProps&&m===e.memoizedState||(t.flags|=4),typeof o.getSnapshotBeforeUpdate!="function"||u===e.memoizedProps&&m===e.memoizedState||(t.flags|=1024),t.memoizedProps=r,t.memoizedState=w),o.props=r,o.state=w,o.context=a,r=f):(typeof o.componentDidUpdate!="function"||u===e.memoizedProps&&m===e.memoizedState||(t.flags|=4),typeof o.getSnapshotBeforeUpdate!="function"||u===e.memoizedProps&&m===e.memoizedState||(t.flags|=1024),r=!1)}return Bs(e,t,n,r,s,l)}function Bs(e,t,n,r,l,s){tc(e,t);var o=(t.flags&128)!==0;if(!r&&!o)return l&&zi(t,n,!1),rt(e,t,s);r=t.stateNode,Vd.current=t;var u=o&&typeof n.getDerivedStateFromError!="function"?null:r.render();return t.flags|=1,e!==null&&o?(t.child=pn(t,e.child,null,s),t.child=pn(t,null,u,s)):ue(e,t,u,s),t.memoizedState=r.state,l&&zi(t,n,!0),t.child}function nc(e){var t=e.stateNode;t.pendingContext?Ti(e,t.pendingContext,t.pendingContext!==t.context):t.context&&Ti(e,t.context,!1),To(e,t.containerInfo)}function Xi(e,t,n,r,l){return dn(),_o(l),t.flags|=256,ue(e,t,n,r),t.child}var Ws={dehydrated:null,treeContext:null,retryLane:0};function Qs(e){return{baseLanes:e,cachePool:null,transitions:null}}function rc(e,t,n){var r=t.pendingProps,l=Q.current,s=!1,o=(t.flags&128)!==0,u;if((u=o)||(u=e!==null&&e.memoizedState===null?!1:(l&2)!==0),u?(s=!0,t.flags&=-129):(e===null||e.memoizedState!==null)&&(l|=1),F(Q,l&1),e===null)return Os(t),e=t.memoizedState,e!==null&&(e=e.dehydrated,e!==null)?(t.mode&1?e.data==="$!"?t.lanes=8:t.lanes=1073741824:t.lanes=1,null):(o=r.children,e=r.fallback,s?(r=t.mode,s=t.child,o={mode:"hidden",children:o},!(r&1)&&s!==null?(s.childLanes=0,s.pendingProps=o):s=El(o,r,0,null),e=Rt(e,r,n,null),s.return=t,e.return=t,s.sibling=e,t.child=s,t.child.memoizedState=Qs(n),t.memoizedState=Ws,e):$o(t,o));if(l=e.memoizedState,l!==null&&(u=l.dehydrated,u!==null))return Ud(e,t,o,r,u,l,n);if(s){s=r.fallback,o=t.mode,l=e.child,u=l.sibling;var a={mode:"hidden",children:r.children};return!(o&1)&&t.child!==l?(r=t.child,r.childLanes=0,r.pendingProps=a,t.deletions=null):(r=wt(l,a),r.subtreeFlags=l.subtreeFlags&14680064),u!==null?s=wt(u,s):(s=Rt(s,o,n,null),s.flags|=2),s.return=t,r.return=t,r.sibling=s,t.child=r,r=s,s=t.child,o=e.child.memoizedState,o=o===null?Qs(n):{baseLanes:o.baseLanes|n,cachePool:null,transitions:o.transitions},s.memoizedState=o,s.childLanes=e.childLanes&~n,t.memoizedState=Ws,r}return s=e.child,e=s.sibling,r=wt(s,{mode:"visible",children:r.children}),!(t.mode&1)&&(r.lanes=n),r.return=t,r.sibling=null,e!==null&&(n=t.deletions,n===null?(t.deletions=[e],t.flags|=16):n.push(e)),t.child=r,t.memoizedState=null,r}function $o(e,t){return t=El({mode:"visible",children:t},e.mode,0,null),t.return=e,e.child=t}function jr(e,t,n,r){return r!==null&&_o(r),pn(t,e.child,null,n),e=$o(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function Ud(e,t,n,r,l,s,o){if(n)return t.flags&256?(t.flags&=-257,r=ts(Error(k(422))),jr(e,t,o,r)):t.memoizedState!==null?(t.child=e.child,t.flags|=128,null):(s=r.fallback,l=t.mode,r=El({mode:"visible",children:r.children},l,0,null),s=Rt(s,l,o,null),s.flags|=2,r.return=t,s.return=t,r.sibling=s,t.child=r,t.mode&1&&pn(t,e.child,null,o),t.child.memoizedState=Qs(o),t.memoizedState=Ws,s);if(!(t.mode&1))return jr(e,t,o,null);if(l.data==="$!"){if(r=l.nextSibling&&l.nextSibling.dataset,r)var u=r.dgst;return r=u,s=Error(k(419)),r=ts(s,r,void 0),jr(e,t,o,r)}if(u=(o&e.childLanes)!==0,me||u){if(r=ee,r!==null){switch(o&-o){case 4:l=2;break;case 16:l=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:l=32;break;case 536870912:l=268435456;break;default:l=0}l=l&(r.suspendedLanes|o)?0:l,l!==0&&l!==s.retryLane&&(s.retryLane=l,nt(e,l),Fe(r,e,l,-1))}return Qo(),r=ts(Error(k(421))),jr(e,t,o,r)}return l.data==="$?"?(t.flags|=128,t.child=e.child,t=ep.bind(null,e),l._reactRetry=t,null):(e=s.treeContext,xe=mt(l.nextSibling),ke=t,B=!0,Ie=null,e!==null&&(_e[Ne++]=Je,_e[Ne++]=qe,_e[Ne++]=It,Je=e.id,qe=e.overflow,It=t),t=$o(t,r.children),t.flags|=4096,t)}function Gi(e,t,n){e.lanes|=t;var r=e.alternate;r!==null&&(r.lanes|=t),Fs(e.return,t,n)}function ns(e,t,n,r,l){var s=e.memoizedState;s===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:r,tail:n,tailMode:l}:(s.isBackwards=t,s.rendering=null,s.renderingStartTime=0,s.last=r,s.tail=n,s.tailMode=l)}function lc(e,t,n){var r=t.pendingProps,l=r.revealOrder,s=r.tail;if(ue(e,t,r.children,n),r=Q.current,r&2)r=r&1|2,t.flags|=128;else{if(e!==null&&e.flags&128)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&Gi(e,n,t);else if(e.tag===19)Gi(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}r&=1}if(F(Q,r),!(t.mode&1))t.memoizedState=null;else switch(l){case"forwards":for(n=t.child,l=null;n!==null;)e=n.alternate,e!==null&&ol(e)===null&&(l=n),n=n.sibling;n=l,n===null?(l=t.child,t.child=null):(l=n.sibling,n.sibling=null),ns(t,!1,l,n,s);break;case"backwards":for(n=null,l=t.child,t.child=null;l!==null;){if(e=l.alternate,e!==null&&ol(e)===null){t.child=l;break}e=l.sibling,l.sibling=n,n=l,l=e}ns(t,!0,n,null,s);break;case"together":ns(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function Vr(e,t){!(t.mode&1)&&e!==null&&(e.alternate=null,t.alternate=null,t.flags|=2)}function rt(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),Ot|=t.lanes,!(n&t.childLanes))return null;if(e!==null&&t.child!==e.child)throw Error(k(153));if(t.child!==null){for(e=t.child,n=wt(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=wt(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function Hd(e,t,n){switch(t.tag){case 3:nc(t),dn();break;case 5:Pa(t);break;case 1:ye(t.type)&&el(t);break;case 4:To(t,t.stateNode.containerInfo);break;case 10:var r=t.type._context,l=t.memoizedProps.value;F(rl,r._currentValue),r._currentValue=l;break;case 13:if(r=t.memoizedState,r!==null)return r.dehydrated!==null?(F(Q,Q.current&1),t.flags|=128,null):n&t.child.childLanes?rc(e,t,n):(F(Q,Q.current&1),e=rt(e,t,n),e!==null?e.sibling:null);F(Q,Q.current&1);break;case 19:if(r=(n&t.childLanes)!==0,e.flags&128){if(r)return lc(e,t,n);t.flags|=128}if(l=t.memoizedState,l!==null&&(l.rendering=null,l.tail=null,l.lastEffect=null),F(Q,Q.current),r)break;return null;case 22:case 23:return t.lanes=0,ec(e,t,n)}return rt(e,t,n)}var sc,Ks,oc,ic;sc=function(e,t){for(var n=t.child;n!==null;){if(n.tag===5||n.tag===6)e.appendChild(n.stateNode);else if(n.tag!==4&&n.child!==null){n.child.return=n,n=n.child;continue}if(n===t)break;for(;n.sibling===null;){if(n.return===null||n.return===t)return;n=n.return}n.sibling.return=n.return,n=n.sibling}};Ks=function(){};oc=function(e,t,n,r){var l=e.memoizedProps;if(l!==r){e=t.stateNode,Pt(We.current);var s=null;switch(n){case"input":l=hs(e,l),r=hs(e,r),s=[];break;case"select":l=Y({},l,{value:void 0}),r=Y({},r,{value:void 0}),s=[];break;case"textarea":l=ys(e,l),r=ys(e,r),s=[];break;default:typeof l.onClick!="function"&&typeof r.onClick=="function"&&(e.onclick=qr)}ws(n,r);var o;n=null;for(f in l)if(!r.hasOwnProperty(f)&&l.hasOwnProperty(f)&&l[f]!=null)if(f==="style"){var u=l[f];for(o in u)u.hasOwnProperty(o)&&(n||(n={}),n[o]="")}else f!=="dangerouslySetInnerHTML"&&f!=="children"&&f!=="suppressContentEditableWarning"&&f!=="suppressHydrationWarning"&&f!=="autoFocus"&&(Hn.hasOwnProperty(f)?s||(s=[]):(s=s||[]).push(f,null));for(f in r){var a=r[f];if(u=l?.[f],r.hasOwnProperty(f)&&a!==u&&(a!=null||u!=null))if(f==="style")if(u){for(o in u)!u.hasOwnProperty(o)||a&&a.hasOwnProperty(o)||(n||(n={}),n[o]="");for(o in a)a.hasOwnProperty(o)&&u[o]!==a[o]&&(n||(n={}),n[o]=a[o])}else n||(s||(s=[]),s.push(f,n)),n=a;else f==="dangerouslySetInnerHTML"?(a=a?a.__html:void 0,u=u?u.__html:void 0,a!=null&&u!==a&&(s=s||[]).push(f,a)):f==="children"?typeof a!="string"&&typeof a!="number"||(s=s||[]).push(f,""+a):f!=="suppressContentEditableWarning"&&f!=="suppressHydrationWarning"&&(Hn.hasOwnProperty(f)?(a!=null&&f==="onScroll"&&$("scroll",e),s||u===a||(s=[])):(s=s||[]).push(f,a))}n&&(s=s||[]).push("style",n);var f=s;(t.updateQueue=f)&&(t.flags|=4)}};ic=function(e,t,n,r){n!==r&&(t.flags|=4)};function En(e,t){if(!B)switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var r=null;n!==null;)n.alternate!==null&&(r=n),n=n.sibling;r===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:r.sibling=null}}function se(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,r=0;if(t)for(var l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags&14680064,r|=l.flags&14680064,l.return=e,l=l.sibling;else for(l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags,r|=l.flags,l.return=e,l=l.sibling;return e.subtreeFlags|=r,e.childLanes=n,t}function Bd(e,t,n){var r=t.pendingProps;switch(jo(t),t.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return se(t),null;case 1:return ye(t.type)&&br(),se(t),null;case 3:return r=t.stateNode,hn(),V(ve),V(ie),Ro(),r.pendingContext&&(r.context=r.pendingContext,r.pendingContext=null),(e===null||e.child===null)&&(Sr(t)?t.flags|=4:e===null||e.memoizedState.isDehydrated&&!(t.flags&256)||(t.flags|=1024,Ie!==null&&(eo(Ie),Ie=null))),Ks(e,t),se(t),null;case 5:zo(t);var l=Pt(er.current);if(n=t.type,e!==null&&t.stateNode!=null)oc(e,t,n,r,l),e.ref!==t.ref&&(t.flags|=512,t.flags|=2097152);else{if(!r){if(t.stateNode===null)throw Error(k(166));return se(t),null}if(e=Pt(We.current),Sr(t)){r=t.stateNode,n=t.type;var s=t.memoizedProps;switch(r[He]=t,r[qn]=s,e=(t.mode&1)!==0,n){case"dialog":$("cancel",r),$("close",r);break;case"iframe":case"object":case"embed":$("load",r);break;case"video":case"audio":for(l=0;l<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[He]=t,e[qn]=r,sc(e,t,!1,!1),t.stateNode=e;e:{switch(o=xs(n,r),n){case"dialog":$("cancel",e),$("close",e),l=r;break;case"iframe":case"object":case"embed":$("load",e),l=r;break;case"video":case"audio":for(l=0;lvn&&(t.flags|=128,r=!0,En(s,!1),t.lanes=4194304)}else{if(!r)if(e=ol(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),En(s,!0),s.tail===null&&s.tailMode==="hidden"&&!o.alternate&&!B)return se(t),null}else 2*G()-s.renderingStartTime>vn&&n!==1073741824&&(t.flags|=128,r=!0,En(s,!1),t.lanes=4194304);s.isBackwards?(o.sibling=t.child,t.child=o):(n=s.last,n!==null?n.sibling=o:t.child=o,s.last=o)}return s.tail!==null?(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=G(),t.sibling=null,n=Q.current,F(Q,r?n&1|2:n&1),t):(se(t),null);case 22:case 23:return Wo(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?we&1073741824&&(se(t),t.subtreeFlags&6&&(t.flags|=8192)):se(t),null;case 24:return null;case 25:return null}throw Error(k(156,t.tag))}function Wd(e,t){switch(jo(t),t.tag){case 1:return ye(t.type)&&br(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return hn(),V(ve),V(ie),Ro(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return zo(t),null;case 13:if(V(Q),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(k(340));dn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return V(Q),null;case 4:return hn(),null;case 10:return Mo(t.type._context),null;case 22:case 23:return Wo(),null;case 24:return null;default:return null}}var _r=!1,oe=!1,Qd=typeof WeakSet=="function"?WeakSet:Set,E=null;function tn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){X(e,t,r)}else n.current=null}function Ys(e,t,n){try{n()}catch(r){X(e,t,r)}}var Zi=!1;function Kd(e,t){if(Ps=Gr,e=da(),So(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,s=r.focusNode;r=r.focusOffset;try{n.nodeType,s.nodeType}catch{n=null;break e}var o=0,u=-1,a=-1,f=0,h=0,v=e,m=null;t:for(;;){for(var x;v!==n||l!==0&&v.nodeType!==3||(u=o+l),v!==s||r!==0&&v.nodeType!==3||(a=o+r),v.nodeType===3&&(o+=v.nodeValue.length),(x=v.firstChild)!==null;)m=v,v=x;for(;;){if(v===e)break t;if(m===n&&++f===l&&(u=o),m===s&&++h===r&&(a=o),(x=v.nextSibling)!==null)break;v=m,m=v.parentNode}v=x}n=u===-1||a===-1?null:{start:u,end:a}}else n=null}n=n||{start:0,end:0}}else n=null;for(Ts={focusedElem:e,selectionRange:n},Gr=!1,E=t;E!==null;)if(t=E,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,E=e;else for(;E!==null;){t=E;try{var w=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(w!==null){var S=w.memoizedProps,T=w.memoizedState,d=t.stateNode,c=d.getSnapshotBeforeUpdate(t.elementType===t.type?S:ze(t.type,S),T);d.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var p=t.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(k(163))}}catch(y){X(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,E=e;break}E=t.return}return w=Zi,Zi=!1,w}function $n(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var s=l.destroy;l.destroy=void 0,s!==void 0&&Ys(t,n,s)}l=l.next}while(l!==r)}}function _l(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Xs(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function uc(e){var t=e.alternate;t!==null&&(e.alternate=null,uc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[He],delete t[qn],delete t[Ds],delete t[Md],delete t[Ld])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function ac(e){return e.tag===5||e.tag===3||e.tag===4}function Ji(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||ac(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Gs(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=qr));else if(r!==4&&(e=e.child,e!==null))for(Gs(e,t,n),e=e.sibling;e!==null;)Gs(e,t,n),e=e.sibling}function Zs(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Zs(e,t,n),e=e.sibling;e!==null;)Zs(e,t,n),e=e.sibling}var te=null,Re=!1;function st(e,t,n){for(n=n.child;n!==null;)cc(e,t,n),n=n.sibling}function cc(e,t,n){if(Be&&typeof Be.onCommitFiberUnmount=="function")try{Be.onCommitFiberUnmount(yl,n)}catch{}switch(n.tag){case 5:oe||tn(n,t);case 6:var r=te,l=Re;te=null,st(e,t,n),te=r,Re=l,te!==null&&(Re?(e=te,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):te.removeChild(n.stateNode));break;case 18:te!==null&&(Re?(e=te,n=n.stateNode,e.nodeType===8?Gl(e.parentNode,n):e.nodeType===1&&Gl(e,n),Yn(e)):Gl(te,n.stateNode));break;case 4:r=te,l=Re,te=n.stateNode.containerInfo,Re=!0,st(e,t,n),te=r,Re=l;break;case 0:case 11:case 14:case 15:if(!oe&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var s=l,o=s.destroy;s=s.tag,o!==void 0&&(s&2||s&4)&&Ys(n,t,o),l=l.next}while(l!==r)}st(e,t,n);break;case 1:if(!oe&&(tn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(u){X(n,t,u)}st(e,t,n);break;case 21:st(e,t,n);break;case 22:n.mode&1?(oe=(r=oe)||n.memoizedState!==null,st(e,t,n),oe=r):st(e,t,n);break;default:st(e,t,n)}}function qi(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Qd),t.forEach(function(r){var l=tp.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Te(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~s}if(r=l,r=G()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Xd(r/1960))-r,10e?16:e,ft===null)var r=!1;else{if(e=ft,ft=null,fl=0,A&6)throw Error(k(331));var l=A;for(A|=4,E=e.current;E!==null;){var s=E,o=s.child;if(E.flags&16){var u=s.deletions;if(u!==null){for(var a=0;aG()-Ho?zt(e,0):Uo|=n),ge(e,t)}function gc(e,t){t===0&&(e.mode&1?(t=vr,vr<<=1,!(vr&130023424)&&(vr=4194304)):t=1);var n=ce();e=nt(e,t),e!==null&&(or(e,t,n),ge(e,n))}function ep(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),gc(e,n)}function tp(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(k(314))}r!==null&&r.delete(t),gc(e,n)}var wc;wc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ve.current)me=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return me=!1,Hd(e,t,n);me=!!(e.flags&131072)}else me=!1,B&&t.flags&1048576&&Ca(t,nl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Vr(e,t),e=t.pendingProps;var l=fn(t,ie.current);un(t,n),l=Io(null,t,r,e,l,n);var s=Ao();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,ye(r)?(s=!0,el(t)):s=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Po(t),l.updater=jl,t.stateNode=l,l._reactInternals=t,Vs(t,r,e,n),t=Bs(null,t,r,!0,s,n)):(t.tag=0,B&&s&&Co(t),ue(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Vr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=rp(r),e=ze(r,e),l){case 0:t=Hs(null,t,r,e,n);break e;case 1:t=Yi(null,t,r,e,n);break e;case 11:t=Qi(null,t,r,e,n);break e;case 14:t=Ki(null,t,r,ze(r.type,e),n);break e}throw Error(k(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Hs(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Yi(e,t,r,l,n);case 3:e:{if(nc(t),e===null)throw Error(k(387));r=t.pendingProps,s=t.memoizedState,l=s.element,La(e,t),sl(t,r,null,n);var o=t.memoizedState;if(r=o.element,s.isDehydrated)if(s={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=s,t.memoizedState=s,t.flags&256){l=mn(Error(k(423)),t),t=Xi(e,t,r,n,l);break e}else if(r!==l){l=mn(Error(k(424)),t),t=Xi(e,t,r,n,l);break e}else for(xe=mt(t.stateNode.containerInfo.firstChild),ke=t,B=!0,Ie=null,n=Ea(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(dn(),r===l){t=rt(e,t,n);break e}ue(e,t,r,n)}t=t.child}return t;case 5:return Pa(t),e===null&&Os(t),r=t.type,l=t.pendingProps,s=e!==null?e.memoizedProps:null,o=l.children,zs(r,l)?o=null:s!==null&&zs(r,s)&&(t.flags|=32),tc(e,t),ue(e,t,o,n),t.child;case 6:return e===null&&Os(t),null;case 13:return rc(e,t,n);case 4:return To(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=pn(t,null,r,n):ue(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Qi(e,t,r,l,n);case 7:return ue(e,t,t.pendingProps,n),t.child;case 8:return ue(e,t,t.pendingProps.children,n),t.child;case 12:return ue(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,s=t.memoizedProps,o=l.value,F(rl,r._currentValue),r._currentValue=o,s!==null)if($e(s.value,o)){if(s.children===l.children&&!ve.current){t=rt(e,t,n);break e}}else for(s=t.child,s!==null&&(s.return=t);s!==null;){var u=s.dependencies;if(u!==null){o=s.child;for(var a=u.firstContext;a!==null;){if(a.context===r){if(s.tag===1){a=be(-1,n&-n),a.tag=2;var f=s.updateQueue;if(f!==null){f=f.shared;var h=f.pending;h===null?a.next=a:(a.next=h.next,h.next=a),f.pending=a}}s.lanes|=n,a=s.alternate,a!==null&&(a.lanes|=n),Fs(s.return,n,t),u.lanes|=n;break}a=a.next}}else if(s.tag===10)o=s.type===t.type?null:s.child;else if(s.tag===18){if(o=s.return,o===null)throw Error(k(341));o.lanes|=n,u=o.alternate,u!==null&&(u.lanes|=n),Fs(o,n,t),o=s.sibling}else o=s.child;if(o!==null)o.return=s;else for(o=s;o!==null;){if(o===t){o=null;break}if(s=o.sibling,s!==null){s.return=o.return,o=s;break}o=o.return}s=o}ue(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,un(t,n),l=Le(l),r=r(l),t.flags|=1,ue(e,t,r,n),t.child;case 14:return r=t.type,l=ze(r,t.pendingProps),l=ze(r.type,l),Ki(e,t,r,l,n);case 15:return ba(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Vr(e,t),t.tag=1,ye(r)?(e=!0,el(t)):e=!1,un(t,n),Za(t,r,l),Vs(t,r,l,n),Bs(null,t,r,!0,e,n);case 19:return lc(e,t,n);case 22:return ec(e,t,n)}throw Error(k(156,t.tag))};function xc(e,t){return Yu(e,t)}function np(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ee(e,t,n,r){return new np(e,t,n,r)}function Ko(e){return e=e.prototype,!(!e||!e.isReactComponent)}function rp(e){if(typeof e=="function")return Ko(e)?1:0;if(e!=null){if(e=e.$$typeof,e===co)return 11;if(e===fo)return 14}return 2}function wt(e,t){var n=e.alternate;return n===null?(n=Ee(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Br(e,t,n,r,l,s){var o=2;if(r=e,typeof e=="function")Ko(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Kt:return Rt(n.children,l,s,t);case ao:o=8,l|=8;break;case cs:return e=Ee(12,n,t,l|2),e.elementType=cs,e.lanes=s,e;case fs:return e=Ee(13,n,t,l),e.elementType=fs,e.lanes=s,e;case ds:return e=Ee(19,n,t,l),e.elementType=ds,e.lanes=s,e;case Pu:return El(n,l,s,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Mu:o=10;break e;case Lu:o=9;break e;case co:o=11;break e;case fo:o=14;break e;case ot:o=16,r=null;break e}throw Error(k(130,e==null?e:typeof e,""))}return t=Ee(o,n,t,l),t.elementType=e,t.type=r,t.lanes=s,t}function Rt(e,t,n,r){return e=Ee(7,e,r,t),e.lanes=n,e}function El(e,t,n,r){return e=Ee(22,e,r,t),e.elementType=Pu,e.lanes=n,e.stateNode={isHidden:!1},e}function rs(e,t,n){return e=Ee(6,e,null,t),e.lanes=n,e}function ls(e,t,n){return t=Ee(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function lp(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Fl(0),this.expirationTimes=Fl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Fl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Yo(e,t,n,r,l,s,o,u,a){return e=new lp(e,t,n,u,a),t===1?(t=1,s===!0&&(t|=8)):t=0,s=Ee(3,null,null,t),e.current=s,s.stateNode=e,s.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Po(s),e}function sp(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(jc)}catch(e){console.error(e)}}jc(),ju.exports=Ce;var cp=ju.exports,ou=cp;us.createRoot=ou.createRoot,us.hydrateRoot=ou.hydrateRoot;function fp({strokeWidth:e=60,...t}){return i.jsx("svg",{viewBox:"0 0 1188 1773",fill:"none",xmlns:"http://www.w3.org/2000/svg",role:"img","aria-hidden":"true",...t,children:i.jsx("path",{d:"M25 561L245 694M25 561V818M245 694V951M25 961V1218M25 1357V1614M245 1489V1747M245 1093V1351M942 823V1080M1161 955V1213M1162 555V812M942 422V679M669 585V843L787 913M942 25V282M1162 158V415M25 818L245 951M244 1094L464 962M25 961L143 890M244 1352L464 1219M942 823L1162 956M942 679L1162 812M721 811L942 679M669 842L724 809M669 586L724 553M1041 883L1162 812M245 1747L1161 1213M244 1490L942 1080M25 1357L142 1289M518 1071L942 823M721 555L942 422M942 422L1162 556M942 282L1162 415M942 25L1162 158M942 1080L1161 1213M25 1218L245 1351M25 961L245 1094M464 962L519 929M464 1219L519 1186V928L403 859M25 1357L245 1490M25 1614L245 1747M25 561L942 25M244 694L941 282M1043 484L1162 415M245 951L668 704",stroke:"currentColor",strokeWidth:e,strokeLinecap:"round"})})}function dp(e){return i.jsxs("svg",{viewBox:"269 80 364 110",fill:"none",xmlns:"http://www.w3.org/2000/svg",role:"img","aria-label":"Patter",...e,children:[i.jsx("path",{d:"M271.422 182.689V85.9524H317.517C324.705 85.9524 330.86 87.2064 335.982 89.7143C341.193 92.2223 345.192 95.7156 347.977 100.194C350.852 104.673 352.29 109.913 352.29 115.914C352.29 121.915 350.852 127.2 347.977 131.768C345.102 136.336 341.058 139.919 335.847 142.516C330.725 145.024 324.615 146.278 317.517 146.278H287.866V130.424H316.439C321.201 130.424 324.885 129.125 327.491 126.528C330.186 123.841 331.534 120.348 331.534 116.048C331.534 111.749 330.186 108.3 327.491 105.703C324.885 103.105 321.201 101.806 316.439 101.806H292.178V182.689H271.422Z",fill:"currentColor"}),i.jsx("path",{d:"M395.375 182.689C394.836 180.718 394.432 178.613 394.162 176.374C393.982 174.135 393.893 171.537 393.893 168.581H393.353V136.202C393.353 133.425 392.41 131.275 390.523 129.752C388.726 128.14 386.03 127.334 382.436 127.334C379.022 127.334 376.281 127.916 374.215 129.081C372.238 130.245 370.935 131.947 370.306 134.186H351.033C351.931 128.006 355.121 122.9 360.602 118.87C366.083 114.839 373.586 112.824 383.11 112.824C392.994 112.824 400.542 115.018 405.753 119.407C410.965 123.796 413.57 130.111 413.57 138.351V168.581C413.57 170.821 413.705 173.105 413.975 175.434C414.334 177.673 414.873 180.091 415.592 182.689H395.375ZM371.384 184.032C364.556 184.032 359.12 182.33 355.076 178.927C351.033 175.434 349.011 170.821 349.011 165.088C349.011 158.729 351.392 153.623 356.154 149.772C361.006 145.83 367.745 143.278 376.371 142.113L396.453 139.292V150.981L379.741 153.533C376.147 154.071 373.496 155.056 371.789 156.489C370.082 157.922 369.228 159.893 369.228 162.401C369.228 164.64 370.037 166.342 371.654 167.507C373.271 168.671 375.428 169.253 378.123 169.253C382.347 169.253 385.941 168.134 388.906 165.894C391.871 163.565 393.353 160.878 393.353 157.833L395.24 168.581C393.264 173.687 390.254 177.538 386.21 180.136C382.167 182.734 377.225 184.032 371.384 184.032Z",fill:"currentColor"}),i.jsx("path",{d:"M450.248 184.167C441.443 184.167 434.883 182.062 430.57 177.852C426.347 173.553 424.236 167.059 424.236 158.37V98.8506L444.453 91.3266V159.042C444.453 162.087 445.306 164.372 447.014 165.894C448.721 167.417 451.371 168.178 454.966 168.178C456.313 168.178 457.571 168.044 458.739 167.775C459.907 167.507 461.075 167.193 462.244 166.835V182.151C461.075 182.778 459.413 183.271 457.257 183.629C455.19 183.988 452.854 184.167 450.248 184.167ZM411.432 129.484V114.167H462.244V129.484H411.432Z",fill:"currentColor"}),i.jsx("path",{d:"M500.501 184.167C491.695 184.167 485.136 182.062 480.823 177.852C476.6 173.553 474.489 167.059 474.489 158.37V98.8506L494.705 91.3266V159.042C494.705 162.087 495.559 164.372 497.266 165.894C498.973 167.417 501.624 168.178 505.218 168.178C506.566 168.178 507.824 168.044 508.992 167.775C510.16 167.507 511.328 167.193 512.496 166.835V182.151C511.328 182.778 509.666 183.271 507.509 183.629C505.443 183.988 503.107 184.167 500.501 184.167ZM461.684 129.484V114.167H512.496V129.484H461.684Z",fill:"currentColor"}),i.jsx("path",{d:"M547.852 184.032C540.214 184.032 533.565 182.554 527.904 179.599C522.244 176.553 517.841 172.343 514.696 166.969C511.641 161.595 510.113 155.414 510.113 148.428C510.113 141.352 511.641 135.171 514.696 129.887C517.841 124.513 522.199 120.348 527.769 117.392C533.34 114.346 539.81 112.824 547.178 112.824C554.276 112.824 560.431 114.257 565.642 117.123C570.854 119.989 574.897 123.975 577.773 129.081C580.648 134.186 582.086 140.187 582.086 147.084C582.086 148.518 582.041 149.861 581.951 151.115C581.861 152.279 581.726 153.399 581.546 154.474H521.974V141.173H565.238L561.734 143.591C561.734 138.038 560.386 133.962 557.69 131.365C555.085 128.678 551.491 127.334 546.908 127.334C541.607 127.334 537.474 129.125 534.508 132.708C531.633 136.291 530.196 141.665 530.196 148.831C530.196 155.818 531.633 161.013 534.508 164.416C537.474 167.82 541.876 169.522 547.717 169.522C550.952 169.522 553.737 168.984 556.073 167.91C558.409 166.835 560.161 165.088 561.33 162.67H580.333C578.087 169.298 574.223 174.538 568.742 178.389C563.351 182.151 556.388 184.032 547.852 184.032Z",fill:"currentColor"}),i.jsx("path",{d:"M586.158 182.689V114.167H605.971V130.29H606.375V182.689H586.158ZM606.375 146.95L604.623 130.693C606.24 124.871 608.891 120.437 612.575 117.392C616.259 114.346 620.842 112.824 626.323 112.824C628.03 112.824 629.288 113.003 630.096 113.361V132.171C629.647 131.992 629.018 131.902 628.21 131.902C627.401 131.813 626.412 131.768 625.244 131.768C618.775 131.768 614.013 132.932 610.958 135.261C607.903 137.5 606.375 141.397 606.375 146.95Z",fill:"currentColor"})]})}function pp(){return i.jsxs("span",{className:"patter-logo",style:{display:"inline-flex",alignItems:"center",gap:8},"aria-label":"Patter",children:[i.jsx(fp,{height:26}),i.jsx(dp,{height:24})]})}function hl(e){const t=Math.floor(e/60),n=Math.floor(e%60);return`${String(t).padStart(2,"0")}:${String(n).padStart(2,"0")}`}function ml(e,t=!0){if(!e)return"";if(t)return e.startsWith("***")?"•••"+e.slice(3):e;if(e.startsWith("***"))return"•••"+e.slice(3);if(e.startsWith("sha256:"))return"••••••••";const n=e.replace(/\D/g,"");return n.length>=4?"•••"+n.slice(-4):"••••••••"}function De(e){if(e==null||!Number.isFinite(e))return"$0.00";const t=Math.abs(e);return t===0?"$0.00":t>=.01?`$${e.toFixed(2)}`:t>=.001?`$${e.toFixed(3)}`:t>=1e-4?`$${e.toFixed(4)}`:`$${e.toFixed(5)}`}function hp(e){return i.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("circle",{cx:"11",cy:"11",r:"7"}),i.jsx("path",{d:"m21 21-4.3-4.3"})]})}function _c(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.4",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M7 13l5 5 5-5"}),i.jsx("path",{d:"M12 4v14"})]})}function mp(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.4",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M17 11l-5-5-5 5"}),i.jsx("path",{d:"M12 20V6"})]})}function vp(e){return i.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"}),i.jsx("circle",{cx:"12",cy:"12",r:"3"})]})}function yp(e){return i.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M17.94 17.94A10.94 10.94 0 0 1 12 19c-6.5 0-10-7-10-7a18.5 18.5 0 0 1 5.06-5.94"}),i.jsx("path",{d:"M9.9 4.24A10.6 10.6 0 0 1 12 4c6.5 0 10 7 10 7a18.8 18.8 0 0 1-2.16 3.19"}),i.jsx("path",{d:"M14.12 14.12a3 3 0 1 1-4.24-4.24"}),i.jsx("path",{d:"M1 1l22 22"})]})}function gp(e){return i.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("circle",{cx:"12",cy:"12",r:"4"}),i.jsx("path",{d:"M12 2v2"}),i.jsx("path",{d:"M12 20v2"}),i.jsx("path",{d:"M4.93 4.93l1.41 1.41"}),i.jsx("path",{d:"M17.66 17.66l1.41 1.41"}),i.jsx("path",{d:"M2 12h2"}),i.jsx("path",{d:"M20 12h2"}),i.jsx("path",{d:"M4.93 19.07l1.41-1.41"}),i.jsx("path",{d:"M17.66 6.34l1.41-1.41"})]})}function wp(e){return i.jsx("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round",...e,children:i.jsx("path",{d:"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"})})}function iu(e){return i.jsxs("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M3 6h18"}),i.jsx("path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"}),i.jsx("path",{d:"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"}),i.jsx("path",{d:"M10 11v6"}),i.jsx("path",{d:"M14 11v6"})]})}function xp(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.2",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M18 6L6 18"}),i.jsx("path",{d:"M6 6l12 12"})]})}function Nc(e){return i.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"3",strokeLinecap:"round",strokeLinejoin:"round",...e,children:i.jsx("path",{d:"M20 6 9 17l-5-5"})})}function kp(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("rect",{x:"9",y:"2",width:"6",height:"12",rx:"3"}),i.jsx("path",{d:"M19 10a7 7 0 0 1-14 0"}),i.jsx("path",{d:"M12 19v3"})]})}function Sp(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("polyline",{points:"15 17 20 12 15 7"}),i.jsx("path",{d:"M4 18v-2a4 4 0 0 1 4-4h12"})]})}function Cp(e){return i.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"currentColor",...e,children:i.jsx("circle",{cx:"12",cy:"12",r:"6"})})}function jp(e){return i.jsxs("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",...e,children:[i.jsx("path",{d:"M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67"}),i.jsx("path",{d:"M22 2 2 22"})]})}function _p({liveCount:e,todayCount:t,phoneNumber:n,sdkVersion:r,revealed:l,dark:s,onToggleRevealed:o,onToggleDark:u}){const a=ml(n,l);return i.jsxs("header",{className:"top",children:[i.jsxs("div",{className:"brand",children:[i.jsx(pp,{}),i.jsxs("span",{className:"tag",children:["dashboard · v",r]})]}),i.jsxs("div",{className:"top-r",children:[i.jsxs("span",{className:"live-chip",children:[i.jsx("span",{className:"pulse"+(e>0?" active":"")}),e," live · ",t," today"]}),n&&n!=="—"&&i.jsx("span",{className:"num-chip",children:a}),i.jsx("button",{type:"button",className:"icon-btn toggle"+(l?" on":""),onClick:o,"aria-label":l?"Hide phone numbers":"Reveal phone numbers","aria-pressed":l,title:l?"Hide numbers":"Reveal numbers",children:l?i.jsx(vp,{}):i.jsx(yp,{})}),i.jsx("button",{type:"button",className:"icon-btn toggle"+(s?" on":""),onClick:u,"aria-label":s?"Switch to light theme":"Switch to dark theme","aria-pressed":s,title:s?"Light mode":"Dark mode",children:s?i.jsx(gp,{}):i.jsx(wp,{})})]})]})}const Np=["1h","24h","7d","All"];function Ep(){const e=document.createElement("a");e.href="/api/dashboard/export/calls?format=csv",e.download="patter_calls.csv",e.rel="noopener",document.body.appendChild(e),e.click(),document.body.removeChild(e)}function Mp({range:e,setRange:t}){return i.jsxs("div",{className:"ph",children:[i.jsxs("div",{children:[i.jsx("h1",{children:"Calls"}),i.jsxs("p",{className:"sub",children:["Real-time view of every call routed through this Patter instance."," ",i.jsx("span",{className:"kbd",children:"⇧K"})," to focus search."]})]}),i.jsxs("div",{className:"filters",children:[i.jsx("div",{className:"seg",children:Np.map(n=>i.jsx("button",{type:"button",className:e===n?"on":"",onClick:()=>t(n),children:n},n))}),i.jsxs("button",{className:"btn",type:"button",onClick:Ep,children:[i.jsx(_c,{})," Export CSV"]})]})]})}const Ec=60*60*1e3,Lp=24*Ec;function Mr(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}function Pp(e){return new Date(e).toLocaleDateString([],{weekday:"short",month:"short",day:"numeric"})}function uu(e){return new Date(e).toLocaleString([],{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}function Mc(e){const t=e.toMs-e.fromMs;return t>=Lp-Tp?Pp(e.fromMs):t>=Ec?`${Mr(e.fromMs)} → ${Mr(e.toMs)}`:t>=60*1e3?`${Mr(e.fromMs)} → ${Mr(e.toMs)}`:`${uu(e.fromMs)} → ${uu(e.toMs)}`}const Tp=5e3;function Lc(e){return e.cost.total??(e.cost.telco??0)+(e.cost.llm??0)+(e.cost.sttTts??0)}function zp(e){return e.calls.length===0?void 0:[...e.calls].sort((n,r)=>(r.startedAtMs??0)-(n.startedAtMs??0))[0]?.id}function Rp(e,t){const n=e.calls,r=n.length;if(t==="spend"){const l=n.reduce((s,o)=>s+Lc(o),0);return{label:"TOTAL COST",value:De(l)}}if(t==="latency"){const l=n.filter(o=>typeof o.latencyP95=="number");return{label:"AVG LATENCY",value:`${l.length>0?Math.round(l.reduce((o,u)=>o+(u.latencyP95??0),0)/l.length):0} ms`}}return{label:r===1?"CALL":"CALLS",value:`${r}`}}function Dp({bucket:e,kind:t}){const n=Mc(e),r=e.calls.length;if(r===0)return i.jsxs("div",{className:"spark-tooltip",children:[i.jsx("div",{className:"spark-tooltip-range",children:n}),i.jsx("div",{className:"spark-tooltip-empty",children:"no calls"})]});const l=Rp(e,t),s=e.calls.slice(0,4);return i.jsxs("div",{className:"spark-tooltip",children:[i.jsx("div",{className:"spark-tooltip-range",children:n}),i.jsxs("div",{className:"spark-tooltip-headline",children:[i.jsx("span",{className:"spark-tooltip-headline-l",children:l.label}),i.jsx("span",{className:"spark-tooltip-headline-v",children:l.value})]}),i.jsx("ul",{className:"spark-tooltip-list",children:s.map(o=>{const u=o.direction==="inbound"?o.from:o.to;return i.jsxs("li",{children:[i.jsx("span",{className:"num",children:u}),i.jsx("span",{className:"status",children:o.status}),i.jsx("span",{className:"cost",children:De(Lc(o))})]},o.id)})}),r>s.length&&i.jsxs("div",{className:"spark-tooltip-more",children:["+",r-s.length," more"]})]})}function Ip({bucket:e,height:t,interactive:n,kind:r,onSelect:l}){const[s,o]=M.useState(!1),u=!!e&&e.calls.length>0;return!n||!e?i.jsx("span",{className:"spark-bar-static",style:{height:t+"%"}}):i.jsxs("div",{className:"spark-bar-wrap",onMouseEnter:()=>o(!0),onMouseLeave:()=>o(!1),children:[i.jsx("button",{type:"button",className:"spark-bar"+(u?"":" empty"),style:{height:t+"%"},disabled:!u,onClick:()=>{if(!u)return;const a=zp(e);a&&l&&l(a)},onFocus:()=>o(!0),onBlur:()=>o(!1),"aria-label":`${e.calls.length} calls in ${Mc(e)}`}),s&&i.jsx(Dp,{bucket:e,kind:r})]})}function Lr({label:e,value:t,unit:n,delta:r,deltaTone:l,spark:s,buckets:o,onSelectCall:u,kind:a="count",peach:f,footer:h,badge:v}){const m=!!o&&!!u;return i.jsxs("div",{className:"metric"+(f?" peach":""),children:[i.jsxs("div",{className:"lbl",children:[i.jsx("span",{children:e}),v&&i.jsx("span",{className:"badge-now",children:"LIVE"})]}),i.jsxs("div",{className:"val",children:[t,n&&i.jsxs("span",{className:"unit",children:[" ",n]})]}),r&&i.jsx("div",{className:"delta "+(l||""),children:r}),h&&i.jsx("div",{className:"delta",children:h}),i.jsx("div",{className:"spark",children:s.map((x,w)=>i.jsx(Ip,{bucket:o?.[w],height:x,interactive:m,kind:a,onSelect:u},w))})]})}function Ap({call:e,isSelected:t,onSelect:n,isNew:r,isChecked:l,onToggleCheck:s,revealed:o}){const u=e.status==="live"&&e.durationStart?hl((Date.now()-e.durationStart)/1e3):hl(e.duration||0),a=e.latencyP95?Math.min(100,e.latencyP95/1e3*100):0,f=(e.latencyP95??0)>600,h=e.cost.total??(e.cost.telco??0)+(e.cost.llm??0)+(e.cost.sttTts??0),v=e.status.replace("-","");return i.jsxs("tr",{className:(t?"selected ":"")+(r?"new-row ":"")+(l?"checked":""),onClick:n,children:[i.jsx("td",{className:"check-cell",onClick:m=>{m.stopPropagation(),s&&s(m)},"aria-disabled":s===null,children:i.jsx("button",{type:"button",className:"row-check"+(l?" on":"")+(s===null?" disabled":""),"aria-label":s===null?"Live calls cannot be deleted":l?"Deselect call":"Select call","aria-pressed":l,disabled:s===null,onClick:m=>{m.stopPropagation(),s&&s(m)},tabIndex:s===null?-1:0,children:l?i.jsx(Nc,{}):null})}),i.jsx("td",{children:i.jsx("span",{className:"pill "+v,children:e.status})}),i.jsxs("td",{children:[i.jsx("span",{className:"dir in",style:{marginRight:8,color:e.direction==="inbound"?"#3b6f3b":"#4a4a4a"},children:e.direction==="inbound"?i.jsx(_c,{}):i.jsx(mp,{})}),i.jsxs("span",{className:"num-cell pii",children:[ml(e.from,o)," → ",ml(e.to,o)]})]}),i.jsx("td",{children:i.jsxs("span",{className:"car-tw",children:[i.jsx("span",{className:"car-dot "+(e.carrier==="twilio"?"tw":"tx")}),e.carrier==="twilio"?"Twilio":"Telnyx"]})}),i.jsx("td",{className:"num-cell",children:e.status==="no-answer"?"—":u}),i.jsx("td",{children:e.latencyP95?i.jsxs(i.Fragment,{children:[i.jsx("span",{className:"lat-bar"+(f?" warn":""),children:i.jsx("i",{style:{width:a+"%"}})}),i.jsxs("span",{className:"num-cell",children:[e.latencyP95," ms"]})]}):"—"}),i.jsx("td",{className:"num-cell",children:De(h)})]})}function Op({calls:e,selectedId:t,onSelect:n,newId:r,search:l,setSearch:s,onDeleteCalls:o,revealed:u}){const a=M.useMemo(()=>{if(!l.trim())return e;const g=l.toLowerCase();return e.filter(j=>j.from.toLowerCase().includes(g)||j.to.toLowerCase().includes(g)||j.status.includes(g)||j.carrier.includes(g)||j.id.includes(g))},[e,l]),[f,h]=M.useState(new Set),[v,m]=M.useState(!1),[x,w]=M.useState(!1),S=M.useMemo(()=>a.filter(g=>g.status!=="live").map(g=>g.id),[a]),T=M.useMemo(()=>S.filter(g=>f.has(g)),[S,f]),d=S.length>0&&T.length===S.length,c=T.length>0,p=g=>{h(j=>{const D=new Set(j);return D.has(g)?D.delete(g):D.add(g),D})},y=()=>{h(g=>{const j=new Set(g);if(d)for(const D of S)j.delete(D);else for(const D of S)j.add(D);return j})},_=()=>{h(new Set),m(!1)},C=async()=>{if(!(!o||T.length===0||x)){w(!0);try{await o(T),_()}finally{w(!1)}}};return i.jsxs("div",{className:"panel",children:[i.jsxs("div",{className:"panel-h",children:[i.jsxs("h3",{children:["Recent calls"," ",i.jsxs("span",{style:{fontFamily:"var(--font-mono)",fontSize:11,color:"#aaa",fontWeight:500,marginLeft:4},children:["(",a.length,")"]})]}),i.jsxs("div",{className:"search",children:[i.jsx(hp,{}),i.jsx("input",{placeholder:"Search number, status, carrier…",value:l,onChange:g=>s(g.target.value)})]}),i.jsxs("span",{className:"sse",children:[i.jsx("span",{className:"dot"}),"streaming · SSE"]})]}),c?i.jsxs("div",{className:"bulk-bar"+(v?" confirming":""),role:"region","aria-label":"Bulk actions",children:[i.jsxs("span",{className:"bulk-count",children:[i.jsx("span",{className:"bulk-num",children:T.length}),i.jsx("span",{className:"bulk-lbl",children:T.length===1?"call selected":"calls selected"})]}),i.jsx("div",{className:"bulk-spacer"}),v?i.jsxs(i.Fragment,{children:[i.jsx("span",{className:"bulk-warn",children:"Removes from view + metrics. Logs kept on disk."}),i.jsx("button",{type:"button",className:"bulk-btn ghost",onClick:()=>m(!1),disabled:x,children:"Cancel"}),i.jsxs("button",{type:"button",className:"bulk-btn destructive",onClick:()=>void C(),disabled:x,autoFocus:!0,children:[i.jsx(iu,{}),i.jsx("span",{children:x?"Deleting…":`Delete ${T.length}`})]})]}):i.jsxs(i.Fragment,{children:[i.jsxs("button",{type:"button",className:"bulk-btn ghost",onClick:_,"aria-label":"Clear selection",children:[i.jsx(xp,{}),i.jsx("span",{children:"Clear"})]}),i.jsxs("button",{type:"button",className:"bulk-btn destructive",onClick:()=>m(!0),children:[i.jsx(iu,{}),i.jsx("span",{children:"Delete"})]})]})]}):null,i.jsx("div",{style:{minHeight:540,maxHeight:540,overflow:"auto"},children:i.jsxs("table",{className:"call-table",children:[i.jsx("thead",{children:i.jsxs("tr",{children:[i.jsx("th",{className:"check-cell",children:i.jsx("button",{type:"button",className:"row-check head"+(d?" on":c?" indet":"")+(S.length===0?" disabled":""),onClick:y,disabled:S.length===0,"aria-label":d?"Deselect all":"Select all calls in view","aria-pressed":d,children:d?i.jsx(Nc,{}):c?i.jsx("span",{className:"indet-mark"}):null})}),i.jsx("th",{children:"Status"}),i.jsx("th",{children:"From → To"}),i.jsx("th",{children:"Carrier"}),i.jsx("th",{children:"Duration"}),i.jsx("th",{children:"p95 latency"}),i.jsx("th",{children:"Cost"})]})}),i.jsx("tbody",{children:a.length===0?i.jsx("tr",{children:i.jsxs("td",{colSpan:7,className:"empty",children:['No calls match "',l,'"']})}):a.map(g=>i.jsx(Ap,{call:g,isSelected:g.id===t,onSelect:()=>n(g.id),isNew:g.id===r,isChecked:f.has(g.id),onToggleCheck:g.status==="live"?null:()=>p(g.id),revealed:u},g.id))})]})})]})}function Fp({start:e}){const[,t]=M.useState(0);return M.useEffect(()=>{const n=setInterval(()=>t(r=>r+1),1e3);return()=>clearInterval(n)},[]),i.jsx(i.Fragment,{children:hl((Date.now()-e)/1e3)})}function $p({call:e,transcript:t,onEnd:n,recording:r,setRecording:l,muted:s,setMuted:o,revealed:u}){const a=M.useRef(null);if(M.useEffect(()=>{a.current&&(a.current.scrollTop=a.current.scrollHeight)},[t]),!e)return i.jsxs("div",{className:"rr-card",children:[i.jsx("h3",{children:"No live call selected"}),i.jsx("div",{className:"meta",children:"Select a call from the table — or wait for the next ring."})]});const f=e.status==="live";return i.jsxs("div",{className:"rr-card",children:[i.jsxs("h3",{children:["Live call",i.jsx("span",{className:"pill "+(f?"live":"done"),children:e.status})]}),i.jsxs("div",{className:"meta",children:[i.jsx("strong",{className:"pii",children:ml(e.direction==="inbound"?e.from:e.to,u)}),i.jsx("span",{className:"sep",children:"·"}),e.agent]}),i.jsxs("div",{className:"duration-block",children:[i.jsx("span",{className:"l",children:"duration"}),i.jsxs("span",{className:"agent",children:[e.direction==="inbound"?"inbound":"outbound"," ·"," ",e.carrier==="twilio"?"Twilio":"Telnyx"]}),i.jsx("span",{className:"v",children:f&&e.durationStart?i.jsx(Fp,{start:e.durationStart}):hl(e.duration||0)})]}),i.jsx("div",{className:"transcript",ref:a,children:t.map((h,v)=>h.who==="tool"?i.jsxs("div",{className:"turn tool",children:[i.jsx("div",{className:"av",children:"⚙"}),i.jsxs("div",{className:"body",children:[i.jsxs("div",{className:"who",children:["tool · ",h.txt]}),h.args&&i.jsx("div",{className:"tool-call",children:Object.entries(h.args).map(([m,x])=>i.jsxs("span",{children:[i.jsxs("span",{className:"k",children:[m,":"]}),' "',String(x),'"'," "]},m))})]})]},v):i.jsxs("div",{className:"turn "+h.who,children:[i.jsx("div",{className:"av",children:h.who==="user"?"U":"P"}),i.jsxs("div",{className:"body",children:[i.jsxs("div",{className:"who",children:[h.who==="user"?"caller":"agent",h.typing&&" · typing"]}),i.jsx("div",{className:"txt",children:h.typing?i.jsxs("span",{className:"typing",children:[i.jsx("span",{}),i.jsx("span",{}),i.jsx("span",{})]}):h.txt}),h.lat&&!h.typing&&i.jsxs("div",{className:"lat",children:[h.lat.stt&&`stt ${h.lat.stt} ms`,h.lat.total&&`total ${h.lat.total} ms · llm ${h.lat.llm} · tts ${h.lat.tts}`]})]})]},v))}),f&&i.jsxs("div",{className:"controls",children:[i.jsxs("button",{type:"button",className:"ctrl"+(s?" active":""),onClick:()=>o(!s),children:[i.jsx(kp,{})," ",s?"unmute":"mute"]}),i.jsxs("button",{type:"button",className:"ctrl",children:[i.jsx(Sp,{})," transfer"]}),i.jsxs("button",{type:"button",className:"ctrl"+(r?" active":""),onClick:()=>l(!r),children:[i.jsx(Cp,{})," ",r?"stop rec":"record"]}),i.jsxs("button",{type:"button",className:"ctrl danger",onClick:n,children:[i.jsx(jp,{})," end"]})]})]})}const Vp=e=>!!e&&typeof e.latencyP95=="number",Up=e=>!!e&&(typeof e.cost.telco=="number"||typeof e.cost.llm=="number"||typeof e.cost.sttTts=="number"||typeof e.cost.total=="number");function Hp({call:e}){const[t,n]=M.useState("latency"),r=Vp(e),l=Up(e);if(!e||!r&&!l)return null;const s=t==="latency"&&!r?"cost":t==="cost"&&!l?"latency":t;return i.jsxs("div",{className:"rr-card metrics-panel",children:[i.jsx("div",{className:"metrics-panel-h",children:i.jsxs("div",{className:"seg",role:"tablist",children:[i.jsx("button",{type:"button",role:"tab","aria-selected":s==="latency",disabled:!r,className:s==="latency"?"on":"",onClick:()=>n("latency"),children:"Latency"}),i.jsx("button",{type:"button",role:"tab","aria-selected":s==="cost",disabled:!l,className:s==="cost"?"on":"",onClick:()=>n("cost"),children:"Cost"})]})}),i.jsxs("div",{className:"metrics-panel-body",children:[s==="latency"&&r&&i.jsx(Bp,{call:e}),s==="cost"&&l&&i.jsx(Wp,{call:e})]})]})}function Bp({call:e}){const t=e.latencyP50??0,n=e.latencyP95??0;if(e.mode==="realtime"){const h=(e.turnCount??0)>=2;return i.jsxs(i.Fragment,{children:[i.jsxs("div",{className:"lat-grid",children:[i.jsxs("div",{className:"latbox",children:[i.jsx("div",{className:"l",children:"end-to-end p50"}),i.jsxs("div",{className:"v",children:[h&&t||"—",h&&i.jsx("span",{className:"u",children:"ms"})]})]}),i.jsxs("div",{className:"latbox"+(h&&n>600?" warn":""),children:[i.jsx("div",{className:"l",children:"end-to-end p95"}),i.jsxs("div",{className:"v",children:[h&&n||"—",h&&i.jsx("span",{className:"u",children:"ms"})]})]})]}),i.jsx("div",{className:"waterfall",children:i.jsxs("div",{className:"wf-row",children:[i.jsx("span",{className:"lbl",children:"e2e"}),i.jsx("span",{className:"track",children:i.jsx("span",{className:"seg-bar llm",style:{left:0,width:Math.min(100,n/1e3*100)+"%"}})}),i.jsx("span",{className:"v",children:n})]})}),i.jsxs("div",{className:"wf-legend",children:[i.jsxs("span",{children:[i.jsx("i",{style:{background:"#DF9367"}}),"end-to-end"]}),i.jsx("span",{style:{marginLeft:"auto"},children:e.agent??"realtime"})]})]})}const l=e.sttAvg||0,s=e.llmAvg||0,o=e.ttsAvg||0,u=l+s+o,a=Math.max(u,800),f=(e.turnCount??0)>=2;return i.jsxs(i.Fragment,{children:[i.jsxs("div",{className:"lat-grid",children:[i.jsxs("div",{className:"latbox",children:[i.jsx("div",{className:"l",children:"p50"}),i.jsxs("div",{className:"v",children:[f?e.latencyP50??"—":"—",f&&i.jsx("span",{className:"u",children:"ms"})]})]}),i.jsxs("div",{className:"latbox"+(f&&n>600?" warn":""),children:[i.jsx("div",{className:"l",children:"p95"}),i.jsxs("div",{className:"v",children:[f?n:"—",f&&i.jsx("span",{className:"u",children:"ms"})]})]}),i.jsxs("div",{className:"latbox",children:[i.jsx("div",{className:"l",children:"stt avg"}),i.jsxs("div",{className:"v",children:[e.sttAvg??"—",i.jsx("span",{className:"u",children:"ms"})]})]}),i.jsxs("div",{className:"latbox",children:[i.jsx("div",{className:"l",children:"tts avg"}),i.jsxs("div",{className:"v",children:[e.ttsAvg??"—",i.jsx("span",{className:"u",children:"ms"})]})]})]}),i.jsxs("div",{className:"waterfall",children:[i.jsxs("div",{className:"wf-row",children:[i.jsx("span",{className:"lbl",children:"stt"}),i.jsx("span",{className:"track",children:i.jsx("span",{className:"seg-bar stt",style:{left:0,width:l/a*100+"%"}})}),i.jsx("span",{className:"v",children:l})]}),i.jsxs("div",{className:"wf-row",children:[i.jsx("span",{className:"lbl",children:"llm"}),i.jsx("span",{className:"track",children:i.jsx("span",{className:"seg-bar llm",style:{left:l/a*100+"%",width:s/a*100+"%"}})}),i.jsx("span",{className:"v",children:s})]}),i.jsxs("div",{className:"wf-row",children:[i.jsx("span",{className:"lbl",children:"tts"}),i.jsx("span",{className:"track",children:i.jsx("span",{className:"seg-bar tts",style:{left:(l+s)/a*100+"%",width:o/a*100+"%"}})}),i.jsx("span",{className:"v",children:o})]})]}),i.jsxs("div",{className:"wf-legend",children:[i.jsxs("span",{children:[i.jsx("i",{style:{background:"#1a1a1a"}}),"stt"]}),i.jsxs("span",{children:[i.jsx("i",{style:{background:"#DF9367"}}),"llm"]}),i.jsxs("span",{children:[i.jsx("i",{style:{background:"#278EFF",opacity:.8}}),"tts"]}),i.jsxs("span",{style:{marginLeft:"auto"},children:["total ",u," ms"]})]})]})}function ss(e){if(e.length===0)return e;const t=e.replace(/(?:_(?:ws|rest|stt|tts|llm))+$/i,"");return t.charAt(0).toUpperCase()+t.slice(1)}function Wp({call:e}){const t=e.cost,n=t.telco??0,r=t.llm??0,l=t.stt??0,s=t.tts??0,o=t.sttTts??0,u=l===0&&s===0?o:0,a=t.cached??0,f=n+r+l+s+u,h=t.total??f-a,v=S=>f>0?S/f*100:0,m=e.sttProvider?`${ss(e.sttProvider)} STT${e.sttModel?` · ${e.sttModel}`:""}`:"STT",x=e.ttsProvider?`${ss(e.ttsProvider)} TTS${e.ttsModel?` · ${e.ttsModel}`:""}`:"TTS",w=e.llmModel?`${e.model?ss(e.model)+" · ":""}${e.llmModel}`:e.model||"LLM";return i.jsxs(i.Fragment,{children:[f>0&&i.jsxs("div",{className:"cost-bar",children:[i.jsx("i",{style:{background:"#cc0000",width:v(n)+"%"}}),i.jsx("i",{style:{background:"#DF9367",width:v(r)+"%"}}),i.jsx("i",{style:{background:"#1a1a1a",width:v(l+u)+"%"}}),i.jsx("i",{style:{background:"#6c6c6c",width:v(s)+"%"}})]}),n>0&&i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:[i.jsx("span",{className:"swatch",style:{background:"#cc0000"}}),e.carrier==="twilio"?"Twilio":"Telnyx"]}),i.jsx("span",{className:"v",children:De(n)})]}),r>0&&i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:[i.jsx("span",{className:"swatch",style:{background:"#DF9367"}}),w]}),i.jsx("span",{className:"v",children:De(r)}),a>0&&i.jsxs("span",{className:"saved",children:["−",De(a)," cached"]})]}),l>0&&i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:[i.jsx("span",{className:"swatch",style:{background:"#1a1a1a"}}),m]}),i.jsx("span",{className:"v",children:De(l)})]}),s>0&&i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:[i.jsx("span",{className:"swatch",style:{background:"#6c6c6c"}}),x]}),i.jsx("span",{className:"v",children:De(s)})]}),u>0&&i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:[i.jsx("span",{className:"swatch",style:{background:"#1a1a1a"}}),"STT / TTS (legacy)"]}),i.jsx("span",{className:"v",children:De(u)})]}),i.jsxs("div",{className:"stack-row",children:[i.jsxs("span",{className:"lbl",children:["Total"," ",e.status==="live"&&i.jsx("span",{style:{fontFamily:"var(--font-mono)",fontSize:10,color:"#aaa",marginLeft:4},children:"(running)"})]}),i.jsx("span",{className:"v",children:De(h)})]})]})}const Ut=e=>typeof e=="object"&&e!==null&&!Array.isArray(e),Tt=e=>typeof e=="string"?e:"",Ae=e=>typeof e=="number"&&Number.isFinite(e)?e:0,ae=e=>typeof e=="number"&&Number.isFinite(e)?e:void 0,Xe=e=>typeof e=="string"&&e.length>0?e:void 0;function Pr(e){if(Ut(e))return{stt_ms:ae(e.stt_ms),llm_ms:ae(e.llm_ms),tts_ms:ae(e.tts_ms),total_ms:ae(e.total_ms),agent_response_ms:ae(e.agent_response_ms),endpoint_ms:ae(e.endpoint_ms),user_speech_duration_ms:ae(e.user_speech_duration_ms)}}function Qp(e){if(Ut(e))return{stt:ae(e.stt),tts:ae(e.tts),llm:ae(e.llm),telephony:ae(e.telephony),total:ae(e.total),llm_cached_savings:ae(e.llm_cached_savings)}}function Kp(e){if(!Ut(e))return null;const t=e.turns;return{duration_seconds:ae(e.duration_seconds),provider_mode:Xe(e.provider_mode),telephony_provider:Xe(e.telephony_provider),stt_provider:Xe(e.stt_provider),tts_provider:Xe(e.tts_provider),llm_provider:Xe(e.llm_provider),stt_model:Xe(e.stt_model),tts_model:Xe(e.tts_model),llm_model:Xe(e.llm_model),cost:Qp(e.cost),latency_avg:Pr(e.latency_avg),latency_p50:Pr(e.latency_p50),latency_p95:Pr(e.latency_p95),latency_p99:Pr(e.latency_p99),turns:Array.isArray(t)?t:void 0}}function Yp(e){if(!Array.isArray(e))return;const t=[];for(const n of e)Ut(n)&&t.push({role:Tt(n.role),text:Tt(n.text),timestamp:Ae(n.timestamp)});return t}function Pc(e){if(!Ut(e))return null;const t=Tt(e.call_id);if(t.length===0)return null;const n=e.turns;return{call_id:t,caller:Tt(e.caller),callee:Tt(e.callee),direction:Tt(e.direction),started_at:Ae(e.started_at),ended_at:ae(e.ended_at),status:Xe(e.status),transcript:Yp(e.transcript),turns:Array.isArray(n)?n:void 0,metrics:Kp(e.metrics)}}function Tc(e){if(!Array.isArray(e))return[];const t=[];for(const n of e){const r=Pc(n);r&&t.push(r)}return t}function Xp(e){return Ut(e)?{stt:Ae(e.stt),tts:Ae(e.tts),llm:Ae(e.llm),telephony:Ae(e.telephony)}:{stt:0,tts:0,llm:0,telephony:0}}function Gp(e){if(!Ut(e))return{total_calls:0,total_cost:0,avg_duration:0,avg_latency_ms:0,cost_breakdown:{stt:0,tts:0,llm:0,telephony:0},active_calls:0};const t=Tt(e.sdk_version);return{total_calls:Ae(e.total_calls),total_cost:Ae(e.total_cost),avg_duration:Ae(e.avg_duration),avg_latency_ms:Ae(e.avg_latency_ms),cost_breakdown:Xp(e.cost_breakdown),active_calls:Ae(e.active_calls),...t?{sdk_version:t}:{}}}async function Jo(e){const t=await fetch(e,{headers:{Accept:"application/json"}});if(!t.ok)throw new Error(`Request to ${e} failed with status ${t.status}`);return t.json()}async function Zp(e=50,t=0){const n=`/api/dashboard/calls?limit=${encodeURIComponent(e)}&offset=${encodeURIComponent(t)}`,r=await Jo(n);return Tc(r)}async function Jp(){const e=await Jo("/api/dashboard/active");return Tc(e)}async function qp(){const e=await Jo("/api/dashboard/aggregates");return Gp(e)}async function bp(e){const t=`/api/dashboard/calls/${encodeURIComponent(e)}`,n=await fetch(t,{headers:{Accept:"application/json"}});if(n.status===404)return null;if(!n.ok)throw new Error(`Request to ${t} failed with status ${n.status}`);const r=await n.json();return Pc(r)}async function eh(e){if(e.length===0)return[];if(e.length===1){const r=`/api/dashboard/calls/${encodeURIComponent(e[0])}`,l=await fetch(r,{method:"DELETE",headers:{Accept:"application/json"}});if(!l.ok)throw new Error(`DELETE ${r} failed with status ${l.status}`);const s=await l.json();return Array.isArray(s.deleted)?s.deleted.filter(o=>typeof o=="string"):[]}const t=await fetch("/api/dashboard/calls/delete",{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({call_ids:e})});if(!t.ok)throw new Error(`POST /api/dashboard/calls/delete failed with status ${t.status}`);const n=await t.json();return Array.isArray(n.deleted)?n.deleted.filter(r=>typeof r=="string"):[]}const th=new Set(["in-progress","initiated"]);function nh(e){if(!e)return"ended";switch(e){case"in-progress":case"initiated":return"live";case"completed":return"ended";case"no-answer":return"no-answer";case"busy":case"failed":case"canceled":case"webhook_error":return"fail";default:return"ended"}}function rh(e){return e==="outbound"?"outbound":"inbound"}function lh(e){return typeof e=="string"&&e.toLowerCase().includes("telnyx")?"telnyx":"twilio"}function sh(e){if(typeof e!="string")return"unknown";const t=e.toLowerCase();return t.includes("realtime")?"realtime":t.includes("convai")?"convai":t.includes("pipeline")?"pipeline":"unknown"}function au(e){return e.length===0?"—":e}function oh(e){const t=e.metrics?.provider_mode;if(!t)return;const n=e.metrics?.llm_provider;return t.startsWith("pipeline")&&n?`${t} · ${n}`:t}function ih(e){const t=e.metrics?.cost;if(!t)return{};const n={};return typeof t.telephony=="number"&&(n.telco=t.telephony),typeof t.llm=="number"&&(n.llm=t.llm),typeof t.stt=="number"&&(n.stt=t.stt),typeof t.tts=="number"&&(n.tts=t.tts),typeof t.llm_cached_savings=="number"&&(n.cached=t.llm_cached_savings),(n.stt!==void 0||n.tts!==void 0)&&(n.sttTts=(n.stt??0)+(n.tts??0)),n.telco===void 0&&n.llm===void 0&&n.sttTts===void 0&&typeof t.total=="number"&&(n.total=t.total),n}function uh(e,t){if(t)return;const n=e.metrics?.duration_seconds;return typeof n=="number"?n:typeof e.ended_at=="number"&&typeof e.started_at=="number"?Math.max(0,e.ended_at-e.started_at):0}function ah(e){if(typeof e.ended_at=="number")return Math.round(Date.now()/1e3-e.ended_at)}function cu(e){const t=nh(e.status),n=t==="live"||e.status!==void 0&&th.has(e.status),r=e.metrics?.latency_avg,l=e.metrics?.latency_p50,s=e.metrics?.latency_p95,o=(Array.isArray(e.metrics?.turns)?e.metrics?.turns?.length:void 0)??(Array.isArray(e.transcript)?e.transcript.length:void 0);return{id:e.call_id,status:t,direction:rh(e.direction),from:au(e.caller),to:au(e.callee),carrier:lh(e.metrics?.telephony_provider),startedAtMs:typeof e.started_at=="number"?e.started_at*1e3:void 0,durationStart:n?e.started_at*1e3:void 0,duration:uh(e,n),latencyP95:s?.agent_response_ms??s?.total_ms??r?.total_ms,latencyP50:l?.agent_response_ms??l?.total_ms??r?.total_ms,sttAvg:r?.stt_ms,ttsAvg:r?.tts_ms,llmAvg:r?.llm_ms,turnCount:o,agentResponseP50:l?.agent_response_ms,agentResponseP95:s?.agent_response_ms,cost:ih(e),agent:oh(e),model:e.metrics?.llm_provider,mode:sh(e.metrics?.provider_mode),sttProvider:e.metrics?.stt_provider,ttsProvider:e.metrics?.tts_provider,sttModel:e.metrics?.stt_model,ttsModel:e.metrics?.tts_model,llmModel:e.metrics?.llm_model,transcriptKey:e.call_id,endedAgo:ah(e)}}function ch(e){const t=e.transcript;if(t&&t.length>0){const l=[];for(const s of t){const o=s.text;switch(s.role){case"user":l.push({who:"user",txt:o});break;case"assistant":l.push({who:"bot",txt:o});break;case"tool":l.push({who:"tool",txt:o});break;default:l.push({who:"bot",txt:o});break}}return l}const n=e.turns;if(!n||n.length===0)return[];const r=[];for(const l of n){if(typeof l!="object"||l===null)continue;const s=l,o=typeof s.user_text=="string"?s.user_text:"",u=typeof s.agent_text=="string"?s.agent_text:"";o.length>0&&r.push({who:"user",txt:o}),u.length>0&&u!=="[interrupted]"&&r.push({who:"bot",txt:u})}return r}const zc=60*1e3,Rc=60*zc,os=24*Rc;function fh(e,t=Date.now()){switch(e){case"1h":{const n=5*zc,r=Math.ceil(t/n)*n,l=r-12*n;return{count:12,bucketSizeMs:n,window:{fromMs:l,toMs:r}}}case"24h":{const n=Rc,r=Math.ceil(t/n)*n,l=r-24*n;return{count:24,bucketSizeMs:n,window:{fromMs:l,toMs:r}}}case"7d":{const n=new Date(t);n.setHours(0,0,0,0);const r=n.getTime()+os,l=r-7*os;return{count:7,bucketSizeMs:os,window:{fromMs:l,toMs:r}}}case"All":default:return{count:9,bucketSizeMs:0,window:{fromMs:0,toMs:t}}}}function dh(e,t){const{fromMs:n,toMs:r}=t;return e.filter(l=>{const s=to(l);return typeof s!="number"?!1:s>=n&&s<=r})}function to(e){if(typeof e.startedAtMs=="number")return e.startedAtMs;if(typeof e.durationStart=="number")return e.durationStart;if(typeof e.endedAgo=="number")return Date.now()-e.endedAgo*1e3}function ph(e){const t=e.cost,n=(t.telco??0)+(t.llm??0)+(t.sttTts??0);return n>0?n:t.total??0}function hh(e){const t=e.reduce((n,r)=>r>n?r:n,0);return t<=0?e.map(()=>0):e.map(n=>Math.round(n/t*100))}function Tr(e,t,n=9,r){const l=typeof n=="object",s=l?n.count:n,o=Math.max(1,Math.floor(s)),u=l?n.window:r,a=l?n.bucketSizeMs:0;let f,h;if(u)f=u.fromMs,h=u.toMs;else{const d=[];for(const c of e){const p=to(c);typeof p=="number"&&d.push(p)}if(d.length===0){const c=Date.now();return{heights:new Array(o).fill(0),buckets:new Array(o).fill(null).map(()=>[]),window:{fromMs:c,toMs:c},bucketSizeMs:0}}f=Math.min(...d),h=Math.max(...d)}const v=Math.max(1,h-f),m=a>0?a:v/o,x=new Array(o).fill(null).map(()=>[]),w=new Array(o).fill(0),S=new Array(o).fill(0);for(const d of e){const c=to(d);if(typeof c!="number"||ch)continue;let p=Math.floor((c-f)/m);p>=o&&(p=o-1),p<0&&(p=0),x[p].push(d),t==="totalCalls"?w[p]+=1:t==="latency"?typeof d.latencyP95=="number"&&(w[p]+=d.latencyP95,S[p]+=1):w[p]+=ph(d)}const T=t==="latency"?w.map((d,c)=>S[c]>0?d/S[c]:0):w;return{heights:hh(T),buckets:x,window:{fromMs:f,toMs:h},bucketSizeMs:m}}const mh=500;function vh(e,t){const n=new Set,r=[];for(const l of e)n.has(l.call_id)||(n.add(l.call_id),r.push(cu(l)));for(const l of t)n.has(l.call_id)||(n.add(l.call_id),r.push(cu(l)));return r}function yh(e,t){const n=new Map(e.map(s=>[s.id,s])),r=new Set(t.map(s=>s.id)),l=t.map(s=>{const o=n.get(s.id);return o?{...o,...s,latencyP95:s.latencyP95??o.latencyP95,latencyP50:s.latencyP50??o.latencyP50,sttAvg:s.sttAvg??o.sttAvg,ttsAvg:s.ttsAvg??o.ttsAvg,llmAvg:s.llmAvg??o.llmAvg,turnCount:s.turnCount??o.turnCount,agentResponseP50:s.agentResponseP50??o.agentResponseP50,agentResponseP95:s.agentResponseP95??o.agentResponseP95,cost:{...o.cost,...s.cost}}:s});for(const s of e)r.has(s.id)||l.push(s);return l.sort((s,o)=>(o.startedAtMs??0)-(s.startedAtMs??0)),l.slice(0,mh)}const gh=1e3,wh=3e4,xh=5,kh=5e3,Sh=["call_start","call_initiated","call_status","call_end","calls_deleted"];function fu(e){return e instanceof Error?e.message:"Unknown error"}function Ch(){const[e,t]=M.useState([]),[n,r]=M.useState(null),[l,s]=M.useState(!1),[o,u]=M.useState(null),a=M.useRef(!0),f=M.useRef(null),h=M.useRef(null),v=M.useRef(null),m=M.useRef(0),x=M.useCallback(()=>{h.current!==null&&(clearTimeout(h.current),h.current=null)},[]),w=M.useCallback(()=>{v.current!==null&&(clearInterval(v.current),v.current=null)},[]),S=M.useCallback(()=>{f.current!==null&&(f.current.close(),f.current=null)},[]),T=M.useCallback(async()=>{try{const[g,j,D]=await Promise.all([Jp(),Zp(50,0),qp()]);if(!a.current)return;t(L=>yh(L,vh(g,j))),r(D),u(null)}catch(g){if(!a.current)return;u(fu(g))}},[]),d=M.useCallback(()=>{v.current===null&&(v.current=setInterval(()=>{T()},kh))},[T]),c=M.useRef(()=>{}),p=M.useCallback(()=>{if(x(),m.current>=xh){d();return}const g=m.current,j=Math.min(wh,gh*Math.pow(2,g));m.current=g+1,h.current=setTimeout(()=>{h.current=null,a.current&&c.current()},j)},[x,d]),y=M.useCallback(()=>{T()},[T]),_=M.useCallback(()=>{S();let g;try{g=new EventSource("/api/dashboard/events")}catch(j){u(fu(j)),p();return}f.current=g,g.onopen=()=>{a.current&&(m.current=0,w(),s(!0))},g.onerror=()=>{a.current&&(s(!1),S(),p())};for(const j of Sh)g.addEventListener(j,y);g.addEventListener("turn_complete",y)},[S,w,y,p]);M.useEffect(()=>{c.current=_},[_]),M.useEffect(()=>(a.current=!0,T(),_(),()=>{a.current=!1,x(),w(),S()}),[]);const C=M.useCallback(g=>{if(g.length===0)return;const j=new Set(g);t(D=>D.filter(L=>!j.has(L.id)))},[]);return{calls:e,aggregates:n,isStreaming:l,error:o,refresh:T,removeCallsLocal:C}}const jh=2e3;function _h(e,t){const[n,r]=M.useState([]),l=M.useRef(!0);return M.useEffect(()=>(l.current=!0,()=>{l.current=!1}),[]),M.useEffect(()=>{if(!e){r([]);return}let s=!1,o=null,u=null;const a=async()=>{try{const h=await bp(e);if(s||!l.current)return;if(h===null){r([]);return}r(ch(h))}catch{}};a();const f=h=>{const v=h;try{return JSON.parse(v.data)?.call_id===e}catch{return!1}};try{u=new EventSource("/api/dashboard/events"),u.addEventListener("turn_complete",h=>{f(h)&&a()}),u.addEventListener("call_end",h=>{f(h)&&a()})}catch{u=null}return t&&(o=setInterval(()=>{a()},jh)),()=>{s=!0,o!==null&&clearInterval(o),u!==null&&u.close()}},[e,t]),n}const du="patter.dashboard.reveal",Dc="patter.dashboard.theme";function Nh(e,t){try{const n=window.localStorage.getItem(e);return n==="1"||n==="true"?!0:n==="0"||n==="false"?!1:t}catch{return t}}function Eh(){try{const e=window.localStorage.getItem(Dc);if(e==="dark")return"dark";if(e==="light")return"light"}catch{}return"light"}function Mh(){const[e,t]=M.useState(()=>Nh(du,!1)),[n,r]=M.useState(()=>Eh());M.useEffect(()=>{try{window.localStorage.setItem(du,e?"1":"0")}catch{}},[e]),M.useEffect(()=>{try{window.localStorage.setItem(Dc,n)}catch{}const o=document.body.classList;n==="dark"?o.add("dark"):o.remove("dark")},[n]);const l=M.useCallback(()=>{t(o=>!o)},[]),s=M.useCallback(()=>{r(o=>o==="dark"?"light":"dark")},[]);return{revealed:e,dark:n==="dark",toggleRevealed:l,toggleDark:s}}const Lh="dev",is={"1h":"1h","24h":"24h","7d":"7d",All:"all-time"};function Ph(e){const t=e.filter(r=>typeof r.latencyP95=="number");if(t.length===0)return 0;const n=t.reduce((r,l)=>r+(l.latencyP95??0),0);return Math.round(n/t.length)}function Th(e){return e.reduce((t,n)=>{if(typeof n.cost.total=="number")return t+n.cost.total;const r=(n.cost.telco??0)+(n.cost.llm??0)+(n.cost.sttTts??0);return t+r},0)}function zh(e){const n=e.find(l=>l.status==="live")??e[0];if(!n)return"";const r=n.direction==="inbound"?n.to:n.from;return r&&r!=="—"?r:""}function Rh(){const{calls:e,aggregates:t,isStreaming:n,error:r,refresh:l,removeCallsLocal:s}=Ch(),{revealed:o,dark:u,toggleRevealed:a,toggleDark:f}=Mh(),[h,v]=M.useState(null),[m,x]=M.useState(""),[w,S]=M.useState("24h"),[T,d]=M.useState(!0),[c,p]=M.useState(!1),y=M.useMemo(()=>fh(w),[w]),_=y.window,C=M.useMemo(()=>{if(w==="All")return e;const I=new Set(dh(e,_).map(H=>H.id));return e.filter(H=>H.status==="live"||I.has(H.id))},[e,w,_]);M.useEffect(()=>{if(h!==null)return;const I=C.find(H=>H.status==="live")??C[0];I&&v(I.id)},[C,h]),M.useEffect(()=>{h!==null&&(C.some(I=>I.id===h)||v(null))},[C,h]),M.useEffect(()=>{const I=H=>{if(!(H.shiftKey&&H.key.toLowerCase()==="k"||H.metaKey&&H.key.toLowerCase()==="k"))return;H.preventDefault(),document.querySelector(".panel-h .search input")?.focus()};return window.addEventListener("keydown",I),()=>window.removeEventListener("keydown",I)},[]);const g=M.useMemo(()=>C.find(I=>I.id===h)??null,[C,h]),j=g?.status==="live",D=_h(g?.id??null,j),L=M.useMemo(()=>e.filter(I=>I.status==="live").length,[e]),pe=M.useMemo(()=>e.filter(I=>I.status==="live"&&I.direction==="inbound").length,[e]),_t=L-pe,Qe=C.length,cr=Ph(C)||t?.avg_latency_ms||0,zl=Th(C)||t?.total_cost||0,xn=zh(e),Ht=typeof t?.sdk_version=="string"&&t.sdk_version||Lh,N=M.useMemo(()=>Tr(C,"totalCalls",y),[C,y]),P=M.useMemo(()=>Tr(C,"latency",y),[C,y]),z=M.useMemo(()=>Tr(C,"spend",y),[C,y]),U=M.useMemo(()=>{const I=e.filter(H=>H.status==="live");return Tr(I,"totalCalls",y)},[e,y]),W=I=>I.heights.map((H,Ye)=>({height:H,calls:I.buckets[Ye],fromMs:I.window.fromMs+Ye*I.bucketSizeMs,toMs:I.window.fromMs+(Ye+1)*I.bucketSizeMs})),Bt=()=>{g&&l().catch(()=>{})},Ke=async I=>{if(I.length!==0){s(I),I.includes(h??"")&&v(null);try{await eh(I)}catch{await l().catch(()=>{})}}};return i.jsxs(i.Fragment,{children:[i.jsx(_p,{liveCount:L,todayCount:Qe,phoneNumber:xn,sdkVersion:Ht,revealed:o,dark:u,onToggleRevealed:a,onToggleDark:f}),i.jsxs("div",{className:"page",children:[i.jsx(Mp,{range:w,setRange:I=>S(I)}),i.jsxs("div",{className:"metrics",children:[i.jsx(Lr,{label:`Calls · ${is[w]}`,value:Qe,spark:N.heights,buckets:W(N),onSelectCall:v,kind:"count"}),i.jsx(Lr,{label:"Avg latency p95",value:cr||0,unit:"ms",spark:P.heights,buckets:W(P),onSelectCall:v,kind:"latency"}),i.jsx(Lr,{label:`Spend · ${is[w]}`,value:De(zl),spark:z.heights,buckets:W(z),onSelectCall:v,kind:"spend"}),i.jsx(Lr,{label:"Active now",value:L,peach:!0,badge:!0,footer:`${pe} inbound · ${_t} outbound`,spark:U.heights,buckets:W(U),onSelectCall:v,kind:"count"})]}),i.jsxs("div",{className:"split",children:[i.jsx(Op,{calls:C,selectedId:h,onSelect:v,newId:null,search:m,setSearch:x,onDeleteCalls:Ke,revealed:o}),i.jsxs("div",{className:"rr",children:[i.jsx($p,{call:g,transcript:D,onEnd:Bt,recording:T,setRecording:d,muted:c,setMuted:p,revealed:o}),i.jsx(Hp,{call:g})]})]}),i.jsxs("div",{className:"statusbar",children:[i.jsxs("div",{className:"group",children:[i.jsx("span",{className:n?"green":"",children:n?"streaming · sse":r?`error · ${r}`:"idle"}),i.jsxs("span",{children:["SDK · ",Ht]})]}),i.jsx("div",{className:"group",children:i.jsxs("span",{children:[L," live · ",Qe," ",is[w]]})})]})]})]})}const Ic=document.getElementById("root");if(!Ic)throw new Error("Patter dashboard: #root element missing");us.createRoot(Ic).render(i.jsx(qc.StrictMode,{children:i.jsx(Rh,{})})); diff --git a/libraries/typescript/src/engines/openai.ts b/libraries/typescript/src/engines/openai.ts index 60818931..a1f855e4 100644 --- a/libraries/typescript/src/engines/openai.ts +++ b/libraries/typescript/src/engines/openai.ts @@ -4,7 +4,11 @@ export interface RealtimeOptions { /** API key. Falls back to OPENAI_API_KEY env var when omitted. */ apiKey?: string; - /** Realtime model. Defaults to gpt-4o-mini-realtime-preview. */ + /** + * Realtime model. Defaults to ``gpt-realtime-mini`` (bumped from the + * deprecated ``gpt-4o-mini-realtime-preview`` on 2026-05-25 for + * parity with the Python SDK and the GA Realtime API surface). + */ model?: string; /** Voice preset. Defaults to alloy. */ voice?: string; @@ -57,7 +61,7 @@ export class Realtime { ); } this.apiKey = key; - this.model = opts.model ?? "gpt-4o-mini-realtime-preview"; + this.model = opts.model ?? "gpt-realtime-mini"; this.voice = opts.voice ?? "alloy"; this.reasoningEffort = opts.reasoningEffort; this.inputAudioTranscriptionModel = opts.inputAudioTranscriptionModel; diff --git a/libraries/typescript/src/index.ts b/libraries/typescript/src/index.ts index 4069c133..9cb30c24 100644 --- a/libraries/typescript/src/index.ts +++ b/libraries/typescript/src/index.ts @@ -109,6 +109,24 @@ export type { AssemblyAIModel, AssemblyAIEncoding } from "./providers/assemblyai export type { CartesiaEncoding } from "./providers/cartesia-stt"; export type { LMNTAudioFormat, LMNTModel, LMNTSampleRate } from "./providers/lmnt-tts"; +// Provider-defined const enums + types. Re-exported here so user code +// can ``import { OpenAIRealtimeModel, ElevenLabsModel, ... } from "getpatter"`` +// without reaching into ``getpatter/providers/*``. Mirrors the Python +// SDK's top-level ``getpatter`` namespace. +export { + OpenAIRealtimeAudioFormat, + OpenAIRealtimeModel, + OpenAIRealtimeVADType, + OpenAITranscriptionModel, + OpenAIVoice, +} from "./providers/openai-realtime"; +export { ElevenLabsModel, ElevenLabsOutputFormat } from "./providers/elevenlabs-tts"; +export { DeepgramModel } from "./providers/deepgram-stt"; +export { CartesiaTTSModel, CartesiaTTSVoiceMode } from "./providers/cartesia-tts"; +export { RimeModel, RimeAudioFormat } from "./providers/rime-tts"; +export { PricingUnit, PRICING_VERSION, PRICING_LAST_UPDATED } from "./pricing"; +export type { PricingUnitValue, ModelPricing } from "./pricing"; + // New namespaced STT classes — options-object constructor with env fallback. export { STT as DeepgramSTT } from "./stt/deepgram"; export type { DeepgramSTTOptions } from "./stt/deepgram"; diff --git a/libraries/typescript/src/metrics.ts b/libraries/typescript/src/metrics.ts index 96c3dce9..0abac23f 100644 --- a/libraries/typescript/src/metrics.ts +++ b/libraries/typescript/src/metrics.ts @@ -245,6 +245,21 @@ export class CallMetricsAccumulator { private _bargeinStoppedAt: number | null = null; private _turnUserText = ''; private _turnSttAudioSeconds = 0; + /** + * Guard against the recordTurnInterrupted / recordTurnComplete race. + * + * A VAD-path barge-in fires ``recordTurnInterrupted`` synchronously + * inside ``handleAudioAsync`` while the in-flight pipeline LLM stream + * keeps unwinding on its own task. When the LLM stream eventually + * exits, the existing pipeline path falls through to + * ``recordTurnComplete``, which would push a second turn for the same + * logical exchange (this time carrying ``user_text=''`` because the + * field was already reset). ``_turnAlreadyClosed`` is flipped by + * ``recordTurnInterrupted`` and read by ``recordTurnComplete`` so the + * late ``recordTurnComplete`` becomes a no-op until the next + * ``startTurn`` re-arms the accumulator. + */ + private _turnAlreadyClosed = false; // Cumulative usage counters private _totalSttAudioSeconds = 0; @@ -371,6 +386,7 @@ export class CallMetricsAccumulator { this._bargeinStoppedAt = null; this._turnUserText = ''; this._turnSttAudioSeconds = 0; + this._turnAlreadyClosed = false; // Reset EOU state for this turn this._vadStoppedAt = null; this._sttFinalAt = null; @@ -569,8 +585,18 @@ export class CallMetricsAccumulator { this._bargeinStoppedAt = ts ?? hrTimeMs(); } - /** Close the current turn cleanly and append a `TurnMetrics` record. */ - recordTurnComplete(agentText: string): TurnMetrics { + /** + * Close the current turn cleanly and append a `TurnMetrics` record. + * + * Returns ``null`` when ``recordTurnInterrupted`` has already closed + * the current turn — this protects against the VAD-barge-in / + * pipeline-LLM race where both paths try to finalise the same logical + * turn and the second would otherwise push a phantom entry with + * ``user_text=''``. The caller treats ``null`` as "nothing to emit"; + * ``emitTurnMetrics`` is already null-safe. + */ + recordTurnComplete(agentText: string): TurnMetrics | null { + if (this._turnAlreadyClosed) return null; const latency = this._computeTurnLatency(); const turn: TurnMetrics = { turn_index: this._turns.length, @@ -583,14 +609,30 @@ export class CallMetricsAccumulator { }; this._turns.push(turn); this._resetTurnState(); + // Bidirectional guard: mark the turn as closed so a late + // recordTurnInterrupted (e.g. from a future refactor that reorders + // the bargein + LLM-unwind paths) becomes a no-op instead of + // overwriting the just-emitted turn record. Mirrors the inverse + // guard in recordTurnInterrupted and keeps the two close paths + // symmetric. + this._turnAlreadyClosed = true; this._eventBus?.emit('turn_ended', { callId: this.callId, turn }); this._eventBus?.emit('metrics_collected', { callId: this.callId, turn }); return turn; } - /** Close the current turn as interrupted (barge-in) and return the recorded metrics. */ + /** + * Close the current turn as interrupted (barge-in) and return the + * recorded metrics. Returns ``null`` when no turn is open, OR when + * ``recordTurnComplete`` has already finalised the current turn — + * bidirectional parity with the guard at the top of + * ``recordTurnComplete``. Prevents an out-of-order interruption (e.g. + * a future refactor that reorders the bargein + LLM-unwind paths) + * from overwriting a turn that the complete path already emitted. + */ recordTurnInterrupted(): TurnMetrics | null { if (this._turnStart === null) return null; + if (this._turnAlreadyClosed) return null; const latency = this._computeTurnLatency(); const turn: TurnMetrics = { turn_index: this._turns.length, @@ -607,6 +649,9 @@ export class CallMetricsAccumulator { this._eventBus?.emit('turn_ended', { callId: this.callId, turn }); this._eventBus?.emit('metrics_collected', { callId: this.callId, turn }); this._resetTurnState(); + // Mark the turn as closed so a late recordTurnComplete from the + // pipeline-LLM unwind path becomes a no-op (see _turnAlreadyClosed). + this._turnAlreadyClosed = true; // Extra paranoia: explicitly null out anchors that have caused leaks // into subsequent turns when a barge-in is in flight. _resetTurnState // already clears them, but keep this belt-and-braces line so future diff --git a/libraries/typescript/src/providers/elevenlabs-tts.ts b/libraries/typescript/src/providers/elevenlabs-tts.ts index 36a0b8bb..2dfd4a0b 100644 --- a/libraries/typescript/src/providers/elevenlabs-tts.ts +++ b/libraries/typescript/src/providers/elevenlabs-tts.ts @@ -179,11 +179,21 @@ export class ElevenLabsTTS { private readonly apiKey: string; private readonly voiceId: string; private readonly modelId: string; - private readonly outputFormat: ElevenLabsOutputFormat; + private _outputFormat: ElevenLabsOutputFormat; + private readonly _outputFormatExplicit: boolean; private readonly voiceSettings: ElevenLabsVoiceSettings | undefined; private readonly languageCode: string | undefined; private readonly chunkSize: number; + /** + * Public view of the (possibly auto-flipped) wire format. Read by the + * stream-handler to decide whether to skip the client-side resample + + * mulaw encode when the bytes are already in the carrier's wire codec. + */ + get outputFormat(): ElevenLabsOutputFormat { + return this._outputFormat; + } + // Overloads: positional form (back-compat, accepts `string` for // outputFormat so existing callers passing arbitrary strings keep // compiling) and options-object form (strongly typed). @@ -205,20 +215,50 @@ export class ElevenLabsTTS { const o = voiceIdOrOptions; this.voiceId = resolveVoiceId(o.voiceId ?? '21m00Tcm4TlvDq8ikWAM'); this.modelId = o.modelId ?? ElevenLabsModel.FLASH_V2_5; - this.outputFormat = o.outputFormat ?? ElevenLabsOutputFormat.PCM_16000; + this._outputFormatExplicit = o.outputFormat !== undefined; + this._outputFormat = o.outputFormat ?? ElevenLabsOutputFormat.PCM_16000; this.voiceSettings = o.voiceSettings; this.languageCode = o.languageCode; this.chunkSize = o.chunkSize ?? 4096; } else { this.voiceId = resolveVoiceId(voiceIdOrOptions); this.modelId = modelId; - this.outputFormat = outputFormat as ElevenLabsOutputFormat; + // Positional 4th-arg form: treat as explicit only when the caller + // passed something different from the default. Mirrors the WS + // variant's _outputFormatExplicit semantics. + this._outputFormatExplicit = + outputFormat !== ElevenLabsOutputFormat.PCM_16000; + this._outputFormat = outputFormat as ElevenLabsOutputFormat; this.voiceSettings = undefined; this.languageCode = undefined; this.chunkSize = 4096; } } + /** + * Hook called by ``StreamHandler.initPipeline`` to advise the carrier + * wire format. When the user did NOT pass an explicit ``outputFormat``, + * auto-flip to the carrier's native codec so the audio bytes ElevenLabs + * returns are already in Twilio/Telnyx wire format — eliminating the + * client-side 16 kHz → 8 kHz resample and PCM → μ-law encode. The + * resample/encode chain was a source of audible artifacts on the + * prewarmed firstMessage (see 0.6.2 acceptance notes — burst delivery + * of resampled audio crackled on the carrier-side jitter buffer). + * + * No-op when the caller passed an explicit ``outputFormat`` (incl. via + * the ``forTwilio`` / ``forTelnyx`` factories) — user wins. + * + * Parity with {@link ElevenLabsWebSocketTTS.setTelephonyCarrier}. + */ + setTelephonyCarrier(carrier: string): void { + if (this._outputFormatExplicit) return; + if (carrier === 'twilio') { + this._outputFormat = ElevenLabsOutputFormat.ULAW_8000; + } else if (carrier === 'telnyx') { + this._outputFormat = ElevenLabsOutputFormat.PCM_16000; + } + } + /** * Construct an instance pre-configured for Twilio Media Streams. * @@ -293,7 +333,7 @@ export class ElevenLabsTTS { * good choice for low-latency telephony. */ async *synthesizeStream(text: string): AsyncGenerator { - const url = `${ELEVENLABS_BASE_URL}/text-to-speech/${encodeURIComponent(this.voiceId)}/stream?output_format=${encodeURIComponent(this.outputFormat)}`; + const url = `${ELEVENLABS_BASE_URL}/text-to-speech/${encodeURIComponent(this.voiceId)}/stream?output_format=${encodeURIComponent(this._outputFormat)}`; const body: Record = { text, diff --git a/libraries/typescript/src/providers/elevenlabs-ws-tts.ts b/libraries/typescript/src/providers/elevenlabs-ws-tts.ts index 30fe67c1..7685831c 100644 --- a/libraries/typescript/src/providers/elevenlabs-ws-tts.ts +++ b/libraries/typescript/src/providers/elevenlabs-ws-tts.ts @@ -160,6 +160,21 @@ export class ElevenLabsWebSocketTTS implements TTSAdapter { */ private adoptedConnection: ElevenLabsParkedWS | null = null; + /** + * Active WS for the in-flight ``synthesizeStream`` call, if any. Set + * when a stream starts, cleared in its ``finally`` block. The + * stream-handler calls ``cancelActiveStream()`` from ``cancelSpeaking`` + * to unblock the generator's inner ``await Promise`` — without + * it, a barge-in on the firstMessage live path leaves the for-await + * stuck waiting for the next frame; ElevenLabs never sends + * ``isFinal=true`` after the consumer breaks, the 30 s frame timeout + * fires post-call, and meanwhile ``initPipeline`` never returns so + * the STT ``onTranscript`` callback never registers and subsequent + * user turns are silently dropped (root cause of the 2026-05-20 + * "first message OK, then no response" symptom). + */ + private activeStreamWs: WebSocket | null = null; + /** * The wire format requested over the ElevenLabs WS. Initially set from * the constructor; ``setTelephonyCarrier`` may auto-flip it to the @@ -219,6 +234,34 @@ export class ElevenLabsWebSocketTTS implements TTSAdapter { this._outputFormat = native; } + /** + * Force-close the WebSocket of any in-flight ``synthesizeStream`` call. + * Called by the stream-handler from ``cancelSpeaking`` (barge-in) so + * the generator's inner ``await Promise`` loop unblocks cleanly + * via the ``onClose`` handler — instead of waiting up to 30 s for the + * ``FRAME_TIMEOUT_MS`` watchdog to fire. No-op when no stream is in + * flight or when the WS is already closing. + * + * Without this, a barge-in during the firstMessage live path left the + * for-await stuck (ElevenLabs never sends ``isFinal=true`` after the + * consumer breaks), ``initPipeline`` never returned, the STT + * ``onTranscript`` callback never registered, and the entire remainder + * of the call was silent for the user. Surfaced during the 2026-05-20 + * acceptance run. + */ + cancelActiveStream(): void { + const ws = this.activeStreamWs; + if (!ws) return; + this.activeStreamWs = null; + try { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + } catch { + /* best-effort — finally block in synthesizeStream will also try */ + } + } + /** Pre-configured for Twilio Media Streams (`ulaw_8000`). */ static forTwilio(opts: Omit): ElevenLabsWebSocketTTS { return new ElevenLabsWebSocketTTS({ @@ -311,6 +354,11 @@ export class ElevenLabsWebSocketTTS implements TTSAdapter { headers: { 'xi-api-key': this.apiKey }, }); } + // Expose the in-flight WS so ``cancelActiveStream`` (called from + // the stream-handler's ``cancelSpeaking``) can force a clean exit + // out of the inner ``await Promise`` loop. Cleared in the + // outer finally so a stale reference can't leak across calls. + this.activeStreamWs = ws; const queue: Buffer[] = []; let done = false; @@ -462,6 +510,11 @@ export class ElevenLabsWebSocketTTS implements TTSAdapter { } } finally { if (connectTimer) clearTimeout(connectTimer); + // Clear the active-stream reference BEFORE we close, so a + // concurrent ``cancelActiveStream`` call from the stream-handler + // observes that the stream is already cleaning itself up and + // avoids a double-close. + if (this.activeStreamWs === ws) this.activeStreamWs = null; // Best-effort EOS so the server stops billing for unconsumed audio. try { if (ws.readyState === WebSocket.OPEN) { diff --git a/libraries/typescript/src/providers/openai-realtime-2.ts b/libraries/typescript/src/providers/openai-realtime-2.ts index 61943daf..ac693516 100644 --- a/libraries/typescript/src/providers/openai-realtime-2.ts +++ b/libraries/typescript/src/providers/openai-realtime-2.ts @@ -113,17 +113,33 @@ export class OpenAIRealtime2Adapter extends OpenAIRealtimeAdapter { transcription: { model: opts.inputAudioTranscriptionModel ?? OpenAITranscriptionModel.WHISPER_1, }, - // Lower threshold (0.3 vs the 0.5 default) because the inbound - // audio is telephony-band (8 kHz) linearly upsampled to 24 kHz — - // the upper 4-12 kHz band is interpolation, not real harmonics, - // and the GA server VAD's default tuning was calibrated against - // studio-quality 24 kHz audio. A more permissive threshold - // recovers reliable speech detection on phone-band input. + // VAD threshold raised back to the OpenAI default (0.5) on + // 2026-05-22. The earlier 0.1 tuning (motivated by the + // upsampled telephony-band loss in high frequencies) made the + // server VAD trigger on the carrier-loopback echo of the + // agent's OWN outbound audio in PSTN no-AEC scenarios. + // Combined with the default ``turn_detection.create_response: + // true``, every phantom ``speech_started`` ended a turn early + // and auto-created a new response that the agent immediately + // spoke over, leading to a runaway loop where the first + // message was repeatedly cut and re-generated. turn_detection: { type: opts.vadType ?? OpenAIRealtimeVADType.SERVER_VAD, - threshold: 0.1, + threshold: 0.5, prefix_padding_ms: 300, silence_duration_ms: opts.silenceDurationMs ?? 500, + // Defer ``response.create`` to the application: when OpenAI's + // server VAD commits an ``input_audio_buffer.committed`` segment + // that turns out to be a Whisper hallucination on silence/echo, + // auto-creating a response would generate a phantom turn (the + // model reads the hallucinated text as user input). Patter + // triggers ``response.create`` explicitly in the Realtime + // stream-handler AFTER validating ``transcript_input`` against + // the hallucination filter. Pair with ``interrupt_response: + // false`` so server VAD also leaves in-flight responses alone — + // barge-in is gated client-side. + create_response: false, + interrupt_response: false, }, }, output: { @@ -292,6 +308,152 @@ export class OpenAIRealtime2Adapter extends OpenAIRealtimeAdapter { this.armHeartbeatAndListener(); } + /** + * GA-API variant of {@link OpenAIRealtimeAdapter.openParkedConnection}. + * Opens a fresh Realtime WS against the GA endpoint, exchanges + * `session.created` → GA-shape `session.update` → `session.updated` + * so the upstream session is fully primed, and returns the OPEN + * socket WITHOUT taking it on `this.ws` or arming the heartbeat / + * message listener. + * + * Used by `Patter.parkProviderConnections` during the carrier + * ringing window so the per-call `StreamHandler` can adopt the + * primed socket at carrier `start` — eliminating the TCP + TLS + + * HTTP-101 + `session.update` ack round-trip from the critical path. + * Saves ~300-600 ms of first-audible-word latency. + * + * Bounded by 8 s. Throws on timeout / handshake failure / GA-side + * rejection. Callers treat any error as a cache miss and fall + * through to the cold {@link connect} path. + * + * Billing safety: confirmed by OpenAI's Managing Realtime Costs + * guide — `session.update` does NOT invoke the model and bills no + * tokens. An idle parked socket costs $0. + */ + override async openParkedConnection(): Promise { + const url = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(this.model)}`; + const ws = new WebSocket(url, { + headers: { Authorization: `Bearer ${this.apiKey}` }, + }); + await new Promise((resolve, reject) => { + let sessionCreated = false; + let settled = false; + const onMessage = (raw: Buffer | string): void => { + let msg: { type?: string; error?: { message?: string } }; + try { + msg = JSON.parse(raw.toString()) as { type?: string; error?: { message?: string } }; + } catch { + return; + } + if (msg.type === 'session.created' && !sessionCreated) { + sessionCreated = true; + try { + ws.send(JSON.stringify({ type: 'session.update', session: this.buildGASessionConfig() })); + } catch (err) { + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + } + } else if (msg.type === 'session.updated') { + cleanup(); + resolve(); + } else if (msg.type === 'error') { + cleanup(); + reject(new Error(`OpenAI Realtime 2 parked-setup error: ${msg.error?.message ?? JSON.stringify(msg)}`)); + } + }; + const onError = (err: Error): void => { + cleanup(); + reject(err); + }; + const cleanup = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + ws.off('message', onMessage); + ws.off('error', onError); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error('OpenAI Realtime 2 park connect timeout')); + }, 8000); + ws.on('message', onMessage); + ws.on('error', onError); + }); + // Application-level keepalive. Empirically, OpenAI's GA Realtime + // edge closes idle parked sockets within ~6-7 s — WS-level PINGs + // alone are not counted as activity. Re-sending the (idempotent) + // `session.update` every 3 s keeps the session alive across the + // 3-15 s ringing window. Cancelled in `adoptWebSocket` when the + // live adapter takes over. Billing safety: `session.update` bills + // no tokens (no model invocation). + const keepalive = setInterval(() => { + if (ws.readyState !== ws.OPEN) { + clearInterval(keepalive); + return; + } + try { + ws.send(JSON.stringify({ type: 'session.update', session: this.buildGASessionConfig() })); + } catch { + clearInterval(keepalive); + } + }, 3000); + (ws as unknown as { _parkedKeepalive?: NodeJS.Timeout })._parkedKeepalive = keepalive; + return ws; + } + + /** + * GA-API variant of {@link OpenAIRealtimeAdapter.adoptWebSocket}. Takes + * over a WS that {@link openParkedConnection} produced (already through + * `session.created` + `session.update` + `session.updated`) and arms + * the heartbeat + message listener so the GA event-translation shim + * is wired up. Skips the cold-connect path — saves ~300-600 ms on + * first audible word. + * + * Caller MUST verify `ws.readyState === OPEN` before calling. If the + * parked WS died between park and adopt, fall back to {@link connect}. + */ + override adoptWebSocket(ws: WebSocket): void { + // Cancel the parked keepalive before the live adapter starts + // sending its own frames — otherwise the interval would race + // input_audio_buffer.append writes on the same socket. + const wsAny = ws as unknown as { _parkedKeepalive?: NodeJS.Timeout }; + if (wsAny._parkedKeepalive) { + clearInterval(wsAny._parkedKeepalive); + delete wsAny._parkedKeepalive; + } + this.ws = ws; + // Re-attach the GA event-translation `ws.on` shim BEFORE + // `armHeartbeatAndListener` registers the persistent message + // listener — otherwise GA event names fall through to the v1 + // dispatcher's no-op branch and audio is silently dropped. This + // mirrors the patch the parent `connect` installs on its + // freshly-opened socket; we apply it to the adopted one too. + const wsRef = ws as unknown as { + on: (event: string, handler: (...args: unknown[]) => void) => unknown; + }; + const originalOn = wsRef.on.bind(ws); + wsRef.on = (event: string, handler: (...args: unknown[]) => void): unknown => { + if (event !== 'message') return originalOn(event, handler); + const wrapped = (raw: unknown, ...rest: unknown[]): void => { + try { + const text = typeof raw === 'string' ? raw : (raw as Buffer).toString(); + const parsed = JSON.parse(text) as { type?: string }; + const t = parsed.type; + if (t && Object.prototype.hasOwnProperty.call(GA_TO_V1_EVENT_NAMES, t)) { + (parsed as { type?: string }).type = GA_TO_V1_EVENT_NAMES[t]; + handler(JSON.stringify(parsed), ...rest); + return; + } + } catch { + /* fall through */ + } + handler(raw, ...rest); + }; + return originalOn(event, wrapped); + }; + this.armHeartbeatAndListener(); + } + /** * GA-API variant of {@link OpenAIRealtimeAdapter.sendFirstMessage}. Two * differences from the v1 path: @@ -398,21 +560,21 @@ export class OpenAIRealtime2Adapter extends OpenAIRealtimeAdapter { } async sendFirstMessage(text: string): Promise { - // Bypass reasoning for the first message: this is a literal "say - // exactly X" instruction, not an open question, so the reasoning - // tier inherited from the session (`reasoningEffort` — typically - // "low" for production voice) only adds time-to-first-audio without - // changing the output. Forcing `minimal` here lets the first message - // start streaming as fast as possible; subsequent VAD-triggered - // `response.create`s continue to use the session's reasoning tier. - this.ws?.send(JSON.stringify({ - type: 'response.create', - response: { - output_modalities: ['audio'], - audio: { output: { voice: this.voice } }, - reasoning: { effort: 'minimal' }, - instructions: `Say exactly the following sentence as your first turn and nothing else: "${text}"`, - }, - })); + // ``reasoning.effort`` is only accepted by the flagship GA variants + // (``gpt-realtime``, ``gpt-realtime-2``). The cost-tier + // ``gpt-realtime-mini`` rejects it with "Unsupported option for + // this model" and the first message never reaches the carrier. + // Forward the field only when the caller explicitly opted into a + // tier — the session.update already configured the inherited tier + // for subsequent VAD-driven turns. + const responseBody: Record = { + output_modalities: ['audio'], + audio: { output: { voice: this.voice } }, + instructions: `Say exactly the following sentence as your first turn and nothing else: "${text}"`, + }; + if (this.options.reasoningEffort !== undefined) { + responseBody.reasoning = { effort: this.options.reasoningEffort }; + } + this.ws?.send(JSON.stringify({ type: 'response.create', response: responseBody })); } } diff --git a/libraries/typescript/src/providers/openai-realtime.ts b/libraries/typescript/src/providers/openai-realtime.ts index 97dd20b6..57a539bb 100644 --- a/libraries/typescript/src/providers/openai-realtime.ts +++ b/libraries/typescript/src/providers/openai-realtime.ts @@ -247,7 +247,6 @@ export class OpenAIRealtimeAdapter { const sock = new WebSocket(url, { headers: { Authorization: `Bearer ${this.apiKey}`, - 'OpenAI-Beta': 'realtime=v1', }, }); const timer = setTimeout(() => { @@ -335,7 +334,6 @@ export class OpenAIRealtimeAdapter { this.ws = new WebSocket(url, { headers: { Authorization: `Bearer ${this.apiKey}`, - 'OpenAI-Beta': 'realtime=v1', }, }); @@ -437,7 +435,6 @@ export class OpenAIRealtimeAdapter { const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${this.apiKey}`, - 'OpenAI-Beta': 'realtime=v1', }, }); await new Promise((resolve, reject) => { @@ -610,22 +607,29 @@ export class OpenAIRealtimeAdapter { */ cancelResponse(): void { if (!this.ws) return; - if (this.currentResponseItemId) { - let audioEndMs = this.currentResponseAudioMs; - if (this.currentResponseFirstAudioAt !== null) { - const elapsedMs = Date.now() - this.currentResponseFirstAudioAt; - audioEndMs = Math.min(audioEndMs, Math.max(elapsedMs, 0)); - } - try { - this.ws.send(JSON.stringify({ - type: 'conversation.item.truncate', - item_id: this.currentResponseItemId, - content_index: 0, - audio_end_ms: audioEndMs, - })); - } catch (err) { - getLogger().debug?.(`conversation.item.truncate failed: ${String(err)}`); - } + if (!this.currentResponseItemId) { + // No response in flight — nothing to cancel. OpenAI Realtime GA + // rejects an unconditional ``response.cancel`` with + // ``response_cancel_not_active``, which surfaces as ERROR-level + // log spam on every phantom VAD ``speech_started`` (echo of + // agent audio, voicemail beep, line noise). Silent no-op here + // keeps the cancel idempotent across stale callers. + return; + } + let audioEndMs = this.currentResponseAudioMs; + if (this.currentResponseFirstAudioAt !== null) { + const elapsedMs = Date.now() - this.currentResponseFirstAudioAt; + audioEndMs = Math.min(audioEndMs, Math.max(elapsedMs, 0)); + } + try { + this.ws.send(JSON.stringify({ + type: 'conversation.item.truncate', + item_id: this.currentResponseItemId, + content_index: 0, + audio_end_ms: audioEndMs, + })); + } catch (err) { + getLogger().debug?.(`conversation.item.truncate failed: ${String(err)}`); } this.ws.send(JSON.stringify({ type: 'response.cancel' })); // Reset per-response tracking so any post-cancel late frames and the @@ -644,6 +648,20 @@ export class OpenAIRealtimeAdapter { this.ws?.send(JSON.stringify({ type: 'response.create' })); } + /** + * Trigger `response.create` with no new user item. + * + * Used by the Realtime stream-handler to drive a response after the + * client-side hallucination filter accepts an + * `input_audio_transcription.completed` event. The server VAD config + * sets `create_response: false` so OpenAI no longer auto-creates a + * response on every `input_audio_buffer.committed`; Patter is now + * responsible for triggering it explicitly when a real user turn lands. + */ + async requestResponse(): Promise { + this.ws?.send(JSON.stringify({ type: 'response.create' })); + } + /** * Make the AI speak ``text`` as its opening line. * diff --git a/libraries/typescript/src/providers/silero-vad.ts b/libraries/typescript/src/providers/silero-vad.ts index e4a1261b..3ec5c63e 100644 --- a/libraries/typescript/src/providers/silero-vad.ts +++ b/libraries/typescript/src/providers/silero-vad.ts @@ -416,6 +416,19 @@ export class SileroVAD implements VADProvider { static forPhoneCall(options: SileroVADOptions = {}): Promise { return SileroVAD.load({ sampleRate: 16000, + // Telephony bumps the activation threshold from the upstream + // 0.5 → 0.8 (with deactivation 0.65) so background voices and + // low-volume audio in the caller's room don't trip barge-in. + // Near-mic speech typically scores 0.85-0.98 on Silero — above + // 0.8 — while a distant second speaker through a phone's noise- + // suppression pipeline lands around 0.4-0.6 and is now correctly + // ignored. Bumped twice during 2026-05-20 acceptance: first 0.5 + // → 0.7 (still triggered on quiet voices), then 0.7 → 0.8. + // Trade-off: a whispered legitimate input may not trigger; + // typical phone-call speakers are unaffected. Pass an explicit + // ``activationThreshold`` to override per call site. + activationThreshold: 0.8, + deactivationThreshold: 0.65, ...options, }); } diff --git a/libraries/typescript/src/providers/twilio-adapter.ts b/libraries/typescript/src/providers/twilio-adapter.ts index 209fbb58..1a3d62e2 100644 --- a/libraries/typescript/src/providers/twilio-adapter.ts +++ b/libraries/typescript/src/providers/twilio-adapter.ts @@ -203,17 +203,37 @@ export class TwilioAdapter { } /** - * Build a minimal ```` - * TwiML document. Mirrors the Python adapter's ``generate_stream_twiml``. + * Build a ```` TwiML document. + * + * ``parameters`` is forwarded as ```` + * children of ````. Twilio Media Streams strips query-string params + * from the ```` before the WS handshake, so + * ```` tags are the supported way to pre-populate + * ``start.customParameters`` on the WS ``start`` frame. Used by the + * inbound path to carry caller / callee through to the bridge. + * + * Mirrors the Python adapter's ``generate_stream_twiml``. */ - static generateStreamTwiml(streamUrl: string): string { - const escaped = streamUrl - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - return ``; + static generateStreamTwiml( + streamUrl: string, + parameters?: Record, + ): string { + const esc = (s: string): string => + s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const escapedUrl = esc(streamUrl); + let paramTags = ''; + if (parameters) { + for (const [name, value] of Object.entries(parameters)) { + if (value == null) continue; + paramTags += ``; + } + } + return `${paramTags}`; } /** Force-complete an in-progress call. */ diff --git a/libraries/typescript/src/server.ts b/libraries/typescript/src/server.ts index 2a409323..c3b71e85 100644 --- a/libraries/typescript/src/server.ts +++ b/libraries/typescript/src/server.ts @@ -1488,10 +1488,15 @@ export class EmbeddedServer { const resolvedCaller = dataCaller || active?.caller || ''; const resolvedCallee = dataCallee || active?.callee || ''; // Fire-and-forget: call logging must never block the voice flow. + const resolvedDirection = + (typeof data.direction === 'string' ? data.direction : '') || + active?.direction || + 'inbound'; void logger .logCallStart(callId, { caller: resolvedCaller, callee: resolvedCallee, + direction: resolvedDirection, telephonyProvider: bridge.telephonyProvider, providerMode: agent.provider ?? '', agent: agentSnapshot(), diff --git a/libraries/typescript/src/services/call-log.ts b/libraries/typescript/src/services/call-log.ts index 83f82f04..a08f5ed0 100644 --- a/libraries/typescript/src/services/call-log.ts +++ b/libraries/typescript/src/services/call-log.ts @@ -89,9 +89,16 @@ function retentionDays(): number { } function redactMode(): RedactMode { - const raw = (process.env.PATTER_LOG_REDACT_PHONE || 'mask').trim().toLowerCase(); + // Default ``full`` (changed from ``mask`` on 2026-05-21): the dashboard + // UI's reveal toggle (``revealed=true`` in ``format.ts:fmtPhone``) cannot + // reconstruct a raw number once the persisted record has already been + // masked, so storing raw on disk is required for the toggle to actually + // work. The on-disk path (platform user data dir) is user-private. + // Override with ``PATTER_LOG_REDACT_PHONE=mask`` for setups that ship + // logs off-host. + const raw = (process.env.PATTER_LOG_REDACT_PHONE || 'full').trim().toLowerCase(); if (raw === 'full' || raw === 'mask' || raw === 'hash_only') return raw; - return 'mask'; + return 'full'; } function redactPhone(raw: string): string { @@ -146,6 +153,7 @@ async function appendJsonl(filePath: string, record: unknown): Promise { export interface CallStartInput { readonly caller?: string; readonly callee?: string; + readonly direction?: string; readonly telephonyProvider?: string; readonly providerMode?: string; readonly agent?: Record; @@ -230,6 +238,7 @@ export class CallLogger { status: 'in_progress', caller: redactPhone(input.caller ?? ''), callee: redactPhone(input.callee ?? ''), + direction: input.direction || 'inbound', telephony_provider: input.telephonyProvider ?? '', provider_mode: input.providerMode ?? '', agent: input.agent ?? {}, diff --git a/libraries/typescript/src/stream-handler.ts b/libraries/typescript/src/stream-handler.ts index 3c68078d..0dfd21b3 100644 --- a/libraries/typescript/src/stream-handler.ts +++ b/libraries/typescript/src/stream-handler.ts @@ -121,12 +121,35 @@ function isValidE164(number: string): boolean { * Short words / phrases that Whisper (and, less often, Deepgram) routinely * emit when fed silence or TTS echo on mulaw 8 kHz. Dropping them as turns * prevents the caller from entering a feedback loop where every silent frame - * triggers a new LLM+TTS turn. + * triggers a new LLM+TTS turn. Parity with Python `_STT_HALLUCINATIONS`. + * + * Whisper-specific full-phrase hallucinations: the model's training set was + * dominated by YouTube captions — on silence / echo it falls back to the most + * common training-set closers. These fire hard on PSTN echo loopback when the + * agent's outbound audio bleeds into the input buffer and the upstream VAD + * commits a "non-empty" segment to transcription. + * Comparison happens against the lower-cased + stripped form. */ const HALLUCINATIONS = new Set([ 'you', 'thank you', 'thanks', 'yeah', 'yes', 'no', 'okay', 'ok', 'uh', 'um', 'mmm', 'hmm', '.', 'bye', 'right', 'cool', + // Whisper YouTube-caption hallucinations + 'thank you for watching', + 'thanks for watching', + 'thank you for watching!', + 'thanks for watching!', + 'thank you so much for watching', + 'thanks for listening', + 'please subscribe', + 'subscribe', + 'music', + '[music]', + '♪', + '[no audio]', + '[silence]', + '[blank_audio]', + '(silence)', ]); // --------------------------------------------------------------------------- @@ -316,13 +339,17 @@ export class StreamHandler { * Same as the AEC variant but for deployments where AEC is OFF * (default on PSTN — Twilio/Telnyx). Without an adaptive filter to * converge, the only justification for a gate is anti-flicker on - * micro-events (cough, click). 100 ms covers the first PSTN echo - * round-trip (~40-100 ms) while allowing barge-in from 100 ms into - * the agent's turn — covering nearly all of any response. - * Previously 250 ms, which blocked barge-in entirely on short (<500 ms) - * agent responses. + * micro-events (cough, click). Raised 100 → 500 ms on 2026-05-19 + * after the 0.6.2 acceptance run showed a phantom VAD speech_start + * firing on the very first inbound frame (~500 ms into the call, + * which is past a 100 ms gate). The phantom barge-in cancelled the + * prewarmed firstMessage, the user heard a clipped (graffiante) + * audio fragment, and the SDK left ``_turnAlreadyClosed=true`` so + * subsequent ``recordTurnComplete`` calls were no-ops. 500 ms + * filters those phantoms while still letting a real interruption + * land within half a second of agent onset. */ - private static readonly MIN_AGENT_SPEAKING_MS_BEFORE_BARGE_IN_NO_AEC = 100; + private static readonly MIN_AGENT_SPEAKING_MS_BEFORE_BARGE_IN_NO_AEC = 500; /** Handle for the pending grace-period timer, so it can be cleared on cleanup. */ private graceTimer: ReturnType | null = null; /** @@ -367,30 +394,12 @@ export class StreamHandler { * coexist without name collisions even when firstMessage finishes while * a Realtime turn is still streaming. */ - private firstMessageMarkCounter = 0; - /** - * Maximum unconfirmed Twilio marks while streaming firstMessage. Each - * chunk is 40 ms of audio at 16 kHz PCM16, so a window of 3 caps - * the in-flight queue at ~120 ms. This means a barge-in's - * ``sendClear`` has at most 120 ms of already-buffered audio to flush - * — vs. ~2-5 s with the previous burst-send code, which was the - * root cause of "firstMessage non interrompibile". Higher values - * smooth playback under jittery RTT (each mark echo adds ~150-250 ms - * RTT on PSTN) at the cost of longer barge-in latency; lower values - * risk under-buffering. 3 hit the smallest barge-in cap without - * audible gaps in 2026-05 acceptance. - */ - private static readonly FIRST_MESSAGE_MARK_WINDOW = 3; - /** - * Per-chunk soft timeout (ms) while awaiting a mark echo. Twilio's - * mark echoes typically arrive within 100-250 ms of audio playback. - * Capping at 500 ms guards against carriers (or test doubles) that - * never echo — without it a stalled echo would deadlock the loop and - * the agent would freeze mid-utterance. On timeout we drop the - * waiter from the queue and continue: playout may glitch by one - * chunk but the call stays alive. - */ - private static readonly MARK_AWAIT_TIMEOUT_MS = 500; + // firstMessageMarkCounter / FIRST_MESSAGE_MARK_WINDOW / + // MARK_AWAIT_TIMEOUT_MS were retired with the move to the Twilio-FIFO- + // trusts model (sendPacedFirstMessageBytes no longer emits marks). + // Marks are still consumed via ``onMark`` for any adapter that wants + // to round-trip one, but the firstMessage path no longer back-pressures + // on them. /** * Minimum drain window (ms) between a ``cancelSpeaking`` and the next * ``beginSpeaking``. 150 ms covers a typical PSTN jitter buffer drain @@ -490,6 +499,24 @@ export class StreamHandler { // No-op — abort() throws nothing in modern runtimes, but be defensive. } } + // Force-close any in-flight TTS streaming socket. Without this, the + // firstMessage live ``synthesizeStream`` path (used when the prewarm + // accumulator hadn't completed before pickup) would block on its + // inner ``await Promise`` for 30 s — ``initPipeline`` would + // never return, the STT ``onTranscript`` callback would never + // register, and every subsequent user turn would be silently + // dropped. Provider-duck-typed: adapters that don't expose + // ``cancelActiveStream`` are no-ops here. + const ttsCancelable = this.tts as + | { cancelActiveStream?: () => void } + | undefined; + if (typeof ttsCancelable?.cancelActiveStream === 'function') { + try { + ttsCancelable.cancelActiveStream(); + } catch (err) { + getLogger().debug(`TTS cancelActiveStream raised: ${String(err)}`); + } + } } /** @@ -509,66 +536,20 @@ export class StreamHandler { this.pendingMarks.length = 0; } - /** - * Push a Twilio ``mark`` event AFTER the corresponding audio chunk and - * return a promise that resolves when the mark is echoed back via - * ``onMark`` (or when ``cancelSpeaking`` drains the queue, or after - * ``MARK_AWAIT_TIMEOUT_MS``). Returns null on non-Twilio carriers — the - * caller is expected to fall back to time-based pacing in that case. - */ - private sendMarkAwaitable(): Promise | null { - if (this.deps.bridge.telephonyProvider !== 'twilio') return null; - this.firstMessageMarkCounter += 1; - const markName = `fm_${this.firstMessageMarkCounter}`; - let resolve!: () => void; - const promise = new Promise((r) => { - resolve = r; - }); - this.pendingMarks.push({ name: markName, resolve, promise }); - try { - this.deps.bridge.sendMark(this.ws, markName, this.streamSid); - } catch (err) { - getLogger().debug(`sendMark failed (${markName}): ${String(err)}`); - // Drop the waiter immediately so the queue doesn't fill with - // never-resolving entries that block the window. - const idx = this.pendingMarks.findIndex((m) => m.name === markName); - if (idx >= 0) this.pendingMarks.splice(idx, 1); - return Promise.resolve(); - } - return promise; - } - - /** - * If the in-flight mark queue is at or above ``FIRST_MESSAGE_MARK_WINDOW`` - * entries, wait for the oldest entry to clear (mark echoed, agent - * cancelled, or per-mark timeout). Repeats until the queue depth is - * within the window — under high RTT the carrier may have several - * marks queued and we want every loop iteration to be naturally back- - * pressured by playback. - */ - private async waitForMarkWindow(): Promise { - while ( - this.isSpeaking && - this.pendingMarks.length >= StreamHandler.FIRST_MESSAGE_MARK_WINDOW - ) { - const oldest = this.pendingMarks[0]; - const timeout = new Promise((resolve) => - setTimeout(resolve, StreamHandler.MARK_AWAIT_TIMEOUT_MS), - ); - await Promise.race([oldest.promise, timeout]); - // Drop the head if it's still the same entry — onMark would - // have already removed it on echo; only a timeout leaves it - // in place. - if (this.pendingMarks[0] === oldest) { - this.pendingMarks.shift(); - } - } - } + // Mark-based back-pressure (sendMarkAwaitable / waitForMarkWindow) + // was removed when sendPacedFirstMessageBytes switched to the + // Twilio-FIFO-trusts model — see that method's doc comment for + // rationale. ``pendingMarks`` and ``onMark`` are still kept so an + // adapter that wants to round-trip a mark for some other purpose can + // still do so without breaking the firstMessage path. /** - * Bytes-per-millisecond for a 16 kHz PCM16 mono stream. Used by the - * non-Twilio firstMessage pacing path to translate chunk size into a - * playout-duration sleep. 16000 samples/sec × 2 bytes = 32 bytes/ms. + * Bytes-per-millisecond for a 16 kHz PCM16 mono stream. Used by + * ``sendPacedFirstMessageBytes`` to translate chunk size into a + * playout-duration sleep so we never deliver faster than the carrier + * can decode + play out (which manifested as severe crackling on the + * HTTP-TTS path with client-side resampling). 16000 samples/sec × 2 + * bytes/sample = 32 bytes/ms. */ private static readonly PCM16_16K_BYTES_PER_MS = 32; @@ -1382,6 +1363,23 @@ export class StreamHandler { /** Handle call stop / stream end. */ /** Handle a carrier-emitted `stop` event signalling the call has ended. */ async handleStop(): Promise { + // Abort any in-flight LLM stream and close any in-flight TTS WS so + // the runPipelineLlm / synthesizeStream awaits unblock immediately + // instead of waiting up to 30 s for their own watchdog timers. + // Without this, the carrier's ``stop`` event ends the call but a + // pending TTS WS frame-wait fires a stale ``LLM loop error`` / + // ``TTS streaming error`` log line tens of seconds later, and in + // rapid-conversation scenarios where the user hangs up mid-response + // the in-flight call kept billing tokens after the carrier was gone. + if (this.llmAbort !== null) { + try { this.llmAbort.abort(); } catch { /* defensive */ } + } + const ttsCancelable = this.tts as + | { cancelActiveStream?: () => void } + | undefined; + if (typeof ttsCancelable?.cancelActiveStream === 'function') { + try { ttsCancelable.cancelActiveStream(); } catch { /* defensive */ } + } // Drop any pending barge-in timer BEFORE we tear down metrics / // adapters. Without this, a call that ends while a barge-in is // pending leaves a setTimeout scheduled to fire ``bargeInConfirmMs`` @@ -1398,7 +1396,6 @@ export class StreamHandler { // ``fm_`` numbering at 1 on the next call. See // ``sendPacedFirstMessageBytes`` for the per-send reset that // protects the within-call path. - this.firstMessageMarkCounter = 0; this.clearGraceTimer(); this.flushResamplers(); await this.closeSttOnce(); @@ -1409,6 +1406,17 @@ export class StreamHandler { /** Handle WebSocket close event. */ /** Tear down adapter, STT/TTS, and per-call state when the carrier WebSocket closes. */ async handleWsClose(): Promise { + // Mirror handleStop's in-flight cleanup so a carrier WebSocket drop + // unblocks LLM / TTS awaits immediately — see comment there. + if (this.llmAbort !== null) { + try { this.llmAbort.abort(); } catch { /* defensive */ } + } + const ttsCancelable = this.tts as + | { cancelActiveStream?: () => void } + | undefined; + if (typeof ttsCancelable?.cancelActiveStream === 'function') { + try { ttsCancelable.cancelActiveStream(); } catch { /* defensive */ } + } // See handleStop — drop pending barge-in timer before cleanup so a // dead handler can never fire a stale recordOverlapEnd callback. this.clearPendingBargeIn(); @@ -1416,7 +1424,6 @@ export class StreamHandler { // carrier WS drop during the paced sender cannot leak unresolved // promises owned by the send loop, and reset the counter. this.drainPendingMarks(); - this.firstMessageMarkCounter = 0; this.clearGraceTimer(); this.flushResamplers(); // Drain STT first so in-flight transcripts fire before onCallEnd. @@ -1451,14 +1458,50 @@ export class StreamHandler { * Maintains a 1-byte carry across calls so unaligned HTTP chunks from * streaming TTS providers never byte-swap the PCM16 samples downstream. */ - private encodePipelineAudio(pcm16k: Buffer): string { - const aligned = this.alignPcm16(pcm16k); + private encodePipelineAudio(audioChunk: Buffer): string { + // Carrier-native fast path: when the TTS adapter is configured to + // emit ``ulaw_8000`` (Twilio wire codec) the bytes coming in are + // already in the format Twilio expects. Skip the 16 kHz → 8 kHz + // resample and the PCM → μ-law encode entirely — base64 the raw + // bytes and hand them to the carrier. This eliminates the client- + // side DSP chain that produced audible artifacts on the prewarmed + // firstMessage during 0.6.2 acceptance (the resampler-bursting + // crackle the user reported). + if (this.ttsOutputFormatNativeForCarrier === true) { + return audioChunk.toString('base64'); + } + const aligned = this.alignPcm16(audioChunk); if (aligned.length === 0) return ''; const pcm8k = this.outboundResampler.process(aligned); const mulaw = pcm16ToMulaw(pcm8k); return mulaw.toString('base64'); } + /** + * Cached result of ``isTtsOutputFormatNativeForCarrier()`` — settled + * once at ``initPipeline`` time after ``setTelephonyCarrier`` has run + * on the TTS adapter. Stable for the call lifetime: changes to the + * adapter's output format mid-call would NOT flip this. ``true`` means + * ``encodePipelineAudio`` can take the bypass path. + */ + private ttsOutputFormatNativeForCarrier: boolean = false; + + /** + * Probe whether the TTS adapter is configured to emit bytes already in + * the carrier's wire codec. Currently: Twilio expects ``ulaw_8000``, + * Telnyx expects ``pcm_16000`` (no client transcode in either case if + * matched). Anything else takes the resample-and-encode path. + */ + private isTtsOutputFormatNativeForCarrier(): boolean { + if (!this.tts) return false; + const fmt = (this.tts as { outputFormat?: string }).outputFormat; + if (typeof fmt !== 'string') return false; + const carrier = this.deps.bridge.telephonyProvider; + if (carrier === 'twilio') return fmt === 'ulaw_8000'; + if (carrier === 'telnyx') return fmt === 'pcm_16000'; + return false; + } + /** * Prepend any carry byte from the previous chunk, return the even-length * portion, and stash the final odd byte (if any) for the next call. @@ -1473,18 +1516,11 @@ export class StreamHandler { return combined.subarray(0, alignedLen); } - /** - * 40 ms @ 16 kHz mono PCM16 = 1280 bytes. Sized to mirror the smallest - * live-TTS chunk boundary so cancel granularity (mark/clear bookkeeping) - * is identical regardless of whether the firstMessage came from the - * prewarm cache or a live ``tts.synthesizeStream`` stream. - */ - private static readonly PREWARM_CHUNK_BYTES = 1280; - /** * Stream a cached firstMessage buffer in pacing-friendly chunks. * - * Splits ``prewarmBytes`` into ``PREWARM_CHUNK_BYTES`` slices and + * Splits ``prewarmBytes`` into 20 ms slices (matching Twilio's PSTN + * frame quantum) and * forwards each through ``deps.bridge.sendAudio`` exactly like the * live TTS path does — preserving Twilio mark/clear granularity. A * single multi-second sendAudio call would push the whole intro into @@ -1501,7 +1537,7 @@ export class StreamHandler { } /** - * Iterate ``bytes`` as ``PREWARM_CHUNK_BYTES``-sized PCM16 slices and + * Iterate ``bytes`` in 20 ms slices (Twilio PSTN frame quantum) and * forward each via ``deps.bridge.sendAudio`` with mark-gated pacing * (Twilio) or playout-time-based pacing (Telnyx). Caps the carrier- * side buffer at ``FIRST_MESSAGE_MARK_WINDOW`` chunks so a barge-in's @@ -1517,48 +1553,52 @@ export class StreamHandler { * metrics. See BUG #128 for the regression this fix targets. */ private async sendPacedFirstMessageBytes(bytes: Buffer): Promise { - // Reset the per-send mark counter so each invocation produces a - // fresh ``fm_1, fm_2, ...`` sequence. Without this the counter - // grows monotonically across turns on a re-used handler and a - // stale ``fm_N`` echo from an earlier turn could match a mark - // name issued later, corrupting the FIFO matching in ``onMark``. - // The queue is also expected empty here by ``cancelSpeaking`` / - // ``handleStop`` / ``handleWsClose``; drain defensively if not. + // Reset any stale mark state defensively — we don't emit marks on + // this path but ``onMark`` and the rest of the handler rely on the + // counter being monotonic across the call lifetime. if (this.pendingMarks.length > 0) this.drainPendingMarks(); - this.firstMessageMarkCounter = 0; let firstChunkSent = false; - // Once the mark window is first filled we switch to playout-time pacing - // to prevent batch-ACK bursts. Before that we send in burst so the first - // FIRST_MESSAGE_MARK_WINDOW chunks pre-fill the PSTN jitter buffer. - let initialFillComplete = false; - for (let i = 0; i < bytes.length; i += StreamHandler.PREWARM_CHUNK_BYTES) { + // Slice on the PSTN/G.711 packet quantum (20 ms). Twilio Media + // Streams emits and consumes 20 ms μ-law frames natively, so each + // ``sendAudio`` corresponds to exactly one carrier-side frame. + const PSTN_FRAME_MS = 20; + const bytesPerMs = this.ttsOutputFormatNativeForCarrier + ? 8 // μ-law 8 kHz native (one byte per sample, 8000 sps) + : StreamHandler.PCM16_16K_BYTES_PER_MS; // 32 bytes/ms for PCM16 16 kHz + const sliceBytes = bytesPerMs * PSTN_FRAME_MS; + // No pacing, no mark gating. Twilio's media-stream protocol + // explicitly buffers and plays frames in order received — its FIFO + // owns the 8 kHz playout clock, not our send loop. Every attempt + // we've made to "help" Twilio (per-chunk sleep, mark back-pressure, + // initial-fill burst, absolute-clock scheduling) introduced its own + // jitter source: setTimeout drift, mark-echo RTT > playout window, + // or burst-then-stall patterns. The result the user heard as + // "scatti" / "differenza di frequenza" was the side effect of our + // pacing fighting the carrier clock, not the carrier itself. + // + // Mirror the pattern used by Twilio's own call-gpt reference sample + // and Pipecat's TwilioFrameSerializer: dump every 20 ms slice into + // the WebSocket back-to-back, return, let Twilio drain. For prewarm + // this is ~250 sendAudio calls in <50 ms for a 5 s greeting; the + // WebSocket buffer absorbs them and the carrier plays at exactly + // 50 frames/s with no further intervention from us. Barge-in still + // works via ``sendClear`` which flushes whatever Twilio has queued + // regardless of marks. + for (let i = 0; i < bytes.length; i += sliceBytes) { if (!this.isSpeaking) break; // barge-in mid-buffer — stop now - // Back-pressure: if too many marks are unconfirmed, wait. Drains - // immediately on cancelSpeaking. - await this.waitForMarkWindow(); - if (!this.isSpeaking) break; - const chunk = bytes.subarray(i, i + StreamHandler.PREWARM_CHUNK_BYTES); + const chunk = bytes.subarray(i, i + sliceBytes); if (!firstChunkSent) firstChunkSent = true; - if (this.aec) this.aec.pushFarEnd(chunk); + // Far-end tap is only valid when the bytes are PCM16 — the AEC's + // ``int16BufferToFloat32`` ingest assumes int16 LE. On the mulaw + // native fast path we MUST NOT push the wire bytes or AEC's + // reference signal becomes garbage. AEC is opt-in (off by default + // on PSTN), so this guard only matters when the caller opted in. + if (this.aec && !this.ttsOutputFormatNativeForCarrier) { + this.aec.pushFarEnd(chunk); + } const encoded = this.encodePipelineAudio(chunk); this.deps.bridge.sendAudio(this.ws, encoded, this.streamSid); this.markFirstAudioSent(); - const markPromise = this.sendMarkAwaitable(); - if (!initialFillComplete && this.pendingMarks.length >= StreamHandler.FIRST_MESSAGE_MARK_WINDOW) { - initialFillComplete = true; - } - // Telnyx has no mark concept — always pace by playout time. - // Twilio: the first FIRST_MESSAGE_MARK_WINDOW chunks go out in burst - // to pre-fill the PSTN jitter buffer (250–1500 ms), then playout-time - // pacing kicks in (via the sticky initialFillComplete flag) to prevent - // batch-ACK bursts from draining the buffer → crackling. - if (markPromise === null || initialFillComplete) { - const playoutMs = Math.max( - 1, - Math.floor(chunk.length / StreamHandler.PCM16_16K_BYTES_PER_MS), - ); - await new Promise((resolve) => setTimeout(resolve, playoutMs)); - } } return firstChunkSent; } @@ -1592,6 +1632,15 @@ export class StreamHandler { getLogger().debug(`TTS setTelephonyCarrier failed (${label}): ${String(e)}`); } } + // Re-evaluate after setTelephonyCarrier so the encodePipelineAudio + // fast path is enabled for the current carrier when the adapter + // auto-flipped (or the user constructed with a native format). + this.ttsOutputFormatNativeForCarrier = this.isTtsOutputFormatNativeForCarrier(); + if (this.ttsOutputFormatNativeForCarrier) { + getLogger().debug( + `TTS outputFormat matches ${this.deps.bridge.telephonyProvider} wire codec — bypassing client-side transcode`, + ); + } } if (!this.stt) { @@ -2527,14 +2576,52 @@ export class StreamHandler { const label = this.deps.bridge.label; this.adapter = this.deps.buildAIAdapter(resolvedPrompt); - try { - await this.adapter.connect(); - getLogger().debug(`AI adapter connected (${label})`); - } catch (e) { - getLogger().error(`AI adapter connect FAILED (${label}):`, e); - // Hang up the telephony call so it doesn't stay connected billing - try { await this.deps.bridge.endCall(this.callId, this.ws); } catch { /* best effort */ } - return; + // Try to adopt a Realtime WS parked during the ringing window. + // When present we skip the cold ``adapter.connect()`` — the + // parked socket has already paid the TCP + TLS + HTTP-101 + + // ``session.update`` ack round-trip (~300-600 ms saved on first + // audible word). Falls back transparently on cache miss / dead + // socket / adapter missing ``adoptWebSocket``. + let parked: import('./client').ParkedProviderConnections | undefined; + if (typeof this.deps.popPrewarmedConnections === 'function') { + try { + parked = this.deps.popPrewarmedConnections(this.callId); + } catch (err) { + getLogger().debug(`popPrewarmedConnections raised: ${String(err)}`); + } + } + const parkedRealtimeWs = parked?.openaiRealtime; + let adoptOk = false; + if (parkedRealtimeWs !== undefined) { + const adapterAny = this.adapter as + | { adoptWebSocket?: (ws: import('ws').WebSocket) => void } + | undefined; + const wsAlive = parkedRealtimeWs.readyState === 1 /* OPEN */; + if (typeof adapterAny?.adoptWebSocket === 'function' && wsAlive) { + try { + adapterAny.adoptWebSocket(parkedRealtimeWs); + getLogger().info( + `[CONNECT] callId=${this.callId} provider=openai_realtime source=adopted ms=0`, + ); + adoptOk = true; + } catch (err) { + getLogger().debug(`Realtime adoptWebSocket failed: ${String(err)}; falling back`); + } + } + if (!adoptOk) { + try { parkedRealtimeWs.close(); } catch { /* ignore */ } + } + } + if (!adoptOk) { + try { + await this.adapter.connect(); + getLogger().debug(`AI adapter connected (${label})`); + } catch (e) { + getLogger().error(`AI adapter connect FAILED (${label}):`, e); + // Hang up the telephony call so it doesn't stay connected billing + try { await this.deps.bridge.endCall(this.callId, this.ws); } catch { /* best effort */ } + return; + } } if (this.deps.agent.firstMessage) { @@ -2708,8 +2795,32 @@ export class StreamHandler { } private async onAdapterTranscriptInput(inputText: string): Promise { + // Hallucination filter: drop Realtime transcript_input events whose text + // matches a known Whisper hallucination phrase (empty, common filler, or + // YouTube-caption closer). These fire on PSTN echo loopback — committing + // them to the LLM would create phantom user turns the caller never spoke. + // Parity with Python stream_handler.py `transcript_input` branch. + const stripped = inputText.trim().toLowerCase(); + if (HALLUCINATIONS.has(stripped) || stripped === '') { + getLogger().debug( + `Realtime transcript_input dropped (likely Whisper hallucination on silence/echo): ${sanitizeLogValue(inputText.slice(0, 60))}`, + ); + this.userTranscriptPending = false; + return; + } getLogger().debug(`User (${this.deps.bridge.label}): ${sanitizeLogValue(inputText)}`); this.history.push({ role: 'user', text: inputText, timestamp: Date.now() }); + // Hallucination filter accepted — drive response.create explicitly now + // that server VAD is configured with create_response: false. Without + // this call the model never generates a reply (the server no longer + // auto-creates a response on input_audio_buffer.committed). Parity with + // Python stream_handler.py which calls + // ``await self._adapter.request_response()`` at this point. + if (this.adapter instanceof OpenAIRealtimeAdapter) { + void this.adapter.requestResponse().catch((err) => + getLogger().debug(`Realtime requestResponse failed: ${String(err)}`), + ); + } // Fallback: if speech_stopped was missed (server VAD disabled, custom // config, ...) still start the turn here so latency is non-zero. if (!this.metricsAcc.turnActive) { @@ -2901,6 +3012,29 @@ export class StreamHandler { } private async onAdapterSpeechInterrupt(): Promise { + // Gate the cancel/flush path with an anti-flicker window similar to + // the pipeline mode. OpenAI's server VAD fires ``speech_started`` on + // echo of the agent's own audio in PSTN no-AEC scenarios (carrier + // loopback feeds our outbound mulaw back into the input buffer). + // Without this gate every phantom ``speech_started`` cancels the + // response — most visibly, the firstMessage gets truncated + // mid-sentence. The Realtime adapter manages its own TTS span so + // ``isSpeaking`` (a pipeline-only flag) stays false; consult the + // adapter's own response-tracking timestamp as a proxy. + if (this.adapter instanceof OpenAIRealtimeAdapter) { + const startedAt = ( + this.adapter as unknown as { currentResponseFirstAudioAt: number | null } + ).currentResponseFirstAudioAt; + if (startedAt !== null) { + const elapsedMs = Date.now() - startedAt; + if (elapsedMs < StreamHandler.MIN_AGENT_SPEAKING_MS_BEFORE_BARGE_IN_NO_AEC) { + getLogger().info( + `Realtime barge-in suppressed (response < gate, ${elapsedMs}ms)`, + ); + return; + } + } + } this.deps.bridge.sendClear(this.ws, this.streamSid); if (this.adapter instanceof OpenAIRealtimeAdapter) this.adapter.cancelResponse(); this.metricsAcc.recordTurnInterrupted(); diff --git a/libraries/typescript/src/tts/elevenlabs-ws.ts b/libraries/typescript/src/tts/elevenlabs-ws.ts index 48bed9c9..4a6f1b70 100644 --- a/libraries/typescript/src/tts/elevenlabs-ws.ts +++ b/libraries/typescript/src/tts/elevenlabs-ws.ts @@ -41,12 +41,18 @@ function resolveApiKey(apiKey: string | undefined): string { function buildOpts(opts: ElevenLabsWebSocketOptions): ElevenLabsWebSocketTTSOptions { // Voice ID default is owned by the provider class — passing ``undefined`` // here lets it apply its own default (parity Python ↔ TS). + // + // CRITICAL: only forward ``outputFormat`` when the caller actually + // passed one. Forwarding a fallback ("pcm_16000") flips the parent's + // ``_outputFormatExplicit`` flag and disables the carrier-aware + // auto-flip in ``setTelephonyCarrier`` — on Twilio the WS would keep + // negotiating PCM16 16 kHz and pay the client-side resample/encode. const out: ElevenLabsWebSocketTTSOptions = { apiKey: resolveApiKey(opts.apiKey), modelId: opts.modelId ?? 'eleven_flash_v2_5', - outputFormat: opts.outputFormat ?? 'pcm_16000', autoMode: opts.autoMode ?? true, }; + if (opts.outputFormat !== undefined) out.outputFormat = opts.outputFormat; if (opts.voiceId !== undefined) out.voiceId = opts.voiceId; if (opts.voiceSettings !== undefined) out.voiceSettings = opts.voiceSettings; if (opts.languageCode !== undefined) out.languageCode = opts.languageCode; diff --git a/libraries/typescript/src/tts/elevenlabs.ts b/libraries/typescript/src/tts/elevenlabs.ts index f8f4c293..92746301 100644 --- a/libraries/typescript/src/tts/elevenlabs.ts +++ b/libraries/typescript/src/tts/elevenlabs.ts @@ -63,10 +63,22 @@ export class TTS extends _ElevenLabsTTS { // Use the parent's options-object overload so optional fields // (languageCode, voiceSettings) reach the underlying provider — // the legacy positional signature drops them silently. + // + // CRITICAL: only forward ``outputFormat`` when the caller actually + // passed one. Forwarding a fallback ("pcm_16000") would flip the + // parent's ``_outputFormatExplicit`` flag to true and disable the + // carrier-aware auto-flip in ``setTelephonyCarrier`` — the prewarm + // path on Twilio would keep emitting PCM16 16 kHz and pay the + // client-side resample/encode that produced the "audio a scatti" + // user report. Leaving the field out lets the parent default to + // PCM_16000 with the explicit-flag cleared so the carrier hook can + // flip to ulaw_8000 at call time. super(resolveApiKey(opts.apiKey), { voiceId: opts.voiceId ?? "EXAVITQu4vr4xnSDxMaL", modelId: opts.modelId ?? "eleven_flash_v2_5", - outputFormat: (opts.outputFormat ?? "pcm_16000") as ElevenLabsOutputFormat, + ...(opts.outputFormat !== undefined + ? { outputFormat: opts.outputFormat as ElevenLabsOutputFormat } + : {}), languageCode: opts.languageCode, voiceSettings: opts.voiceSettings as never, }); diff --git a/libraries/typescript/src/types.ts b/libraries/typescript/src/types.ts index b1561c83..3551aca2 100644 --- a/libraries/typescript/src/types.ts +++ b/libraries/typescript/src/types.ts @@ -470,15 +470,16 @@ export interface AgentOptions { */ prewarm?: boolean; /** - * When ``true`` (default ``false``), ``Patter.call`` also pre-renders - * ``firstMessage`` to TTS audio bytes during the ringing window and - * streams the cached buffer immediately when the carrier emits - * ``start``. Eliminates the 200-700 ms TTS first-byte latency on the - * greeting at the cost of paying the TTS bill even if the call is - * never answered (silently logged at warn level when the call - * fails). Off by default to preserve the prior cost surface; opt-in - * for production outbound where every millisecond of greeting - * latency hurts conversion. Default: ``false``. + * When ``true`` (default since 0.6.2 in pipeline mode), ``Patter.call`` + * pre-renders ``firstMessage`` to TTS audio bytes during the ringing + * window and streams the cached buffer immediately when the carrier + * emits ``start``. Eliminates the 200-700 ms TTS first-byte latency + * on the greeting that dominated first-turn ``p95`` on every pipeline + * acceptance run. The trade-off is paying the TTS bill even if the + * call is never answered (silently logged at warn level when the call + * fails) — typically $0.001-$0.005 per ringing call depending on TTS + * provider. Opt out by passing ``prewarmFirstMessage: false`` (e.g. + * for very high-volume outbound where un-answered TTS spend matters). * * **Pipeline mode only.** Realtime / ConvAI provider modes never * consume the prewarm cache (the StreamHandler for those modes runs diff --git a/libraries/typescript/src/version.ts b/libraries/typescript/src/version.ts index 5b6ec49d..81d74d2d 100644 --- a/libraries/typescript/src/version.ts +++ b/libraries/typescript/src/version.ts @@ -1,8 +1,24 @@ /** - * SDK version constant — kept in sync with ``package.json``. + * SDK version constant — auto-derived from ``package.json`` at runtime. * - * Hard-coded (rather than imported from ``package.json``) so the SDK works in - * both bundled (no JSON loader) and ESM/CJS dual-export environments without - * platform-specific JSON-import flags. + * tsup builds with ``shims: true`` so ``__dirname`` resolves to the + * dist directory in both CJS and ESM. Reading ``../package.json`` + * from there always lands on the installed package's manifest. The + * fallback covers the (unlikely) case where the file is missing. + * + * Source of truth: ``libraries/typescript/package.json#version``. */ -export const VERSION = '0.5.5'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +function readVersion(): string { + try { + const pkgPath = path.resolve(__dirname, '..', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string }; + return typeof pkg.version === 'string' && pkg.version.length > 0 ? pkg.version : ''; + } catch { + return ''; + } +} + +export const VERSION: string = readVersion(); diff --git a/libraries/typescript/tests/metrics.test.ts b/libraries/typescript/tests/metrics.test.ts index befaa651..b6c47f83 100644 --- a/libraries/typescript/tests/metrics.test.ts +++ b/libraries/typescript/tests/metrics.test.ts @@ -58,6 +58,92 @@ describe('CallMetricsAccumulator', () => { expect(turn!.tts_characters).toBe(0); }); + it('recordTurnComplete is a no-op after recordTurnInterrupted on the same turn', () => { + // Repro of the VAD-barge-in / pipeline-LLM race documented in + // BUGS.md (2026-05-05). The barge-in path closes the turn with + // recordTurnInterrupted while the in-flight pipeline LLM stream + // eventually unwinds and reaches recordTurnComplete. Without the + // guard, the late call would push a phantom turn with user_text='' + // (since _resetTurnState cleared the field) and agent_text from + // the cancelled LLM stream. + const acc = new CallMetricsAccumulator({ + callId: 'race1', + providerMode: 'pipeline', + telephonyProvider: 'twilio', + }); + + acc.startTurn(); + acc.recordSttComplete('Hello'); + const interrupted = acc.recordTurnInterrupted(); + expect(interrupted).not.toBeNull(); + expect(interrupted!.user_text).toBe('Hello'); + expect(interrupted!.agent_text).toBe('[interrupted]'); + + // Late pipeline-LLM unwind reaches recordTurnComplete with the + // cancelled responseText — must be silently dropped. + const late = acc.recordTurnComplete('partial LLM output'); + expect(late).toBeNull(); + + // Only the interrupted turn is recorded. + const result = acc.endCall(); + expect(result.turns).toHaveLength(1); + expect(result.turns[0].agent_text).toBe('[interrupted]'); + expect(result.turns[0].user_text).toBe('Hello'); + }); + + it('recordTurnInterrupted is a no-op after recordTurnComplete on the same turn', () => { + // Bidirectional parity: a late recordTurnInterrupted after + // recordTurnComplete on the same turn must also be a no-op. The + // current caller ordering can't trigger this (the VAD bargein path + // fires the interrupt FIRST and the LLM-unwind path then calls + // complete second, guarded by the existing one-directional guard). + // The symmetric guard hardens the accumulator against a future + // refactor that reorders those paths. + const acc = new CallMetricsAccumulator({ + callId: 'race-bi', + providerMode: 'pipeline', + telephonyProvider: 'twilio', + }); + + acc.startTurn(); + acc.recordSttComplete('Hello'); + const completed = acc.recordTurnComplete('Hi there'); + expect(completed).not.toBeNull(); + expect(completed!.user_text).toBe('Hello'); + expect(completed!.agent_text).toBe('Hi there'); + + // Late VAD-bargein interruption arrives after the complete — + // must be silently dropped. + const late = acc.recordTurnInterrupted(); + expect(late).toBeNull(); + + // Only the completed turn is recorded. + const result = acc.endCall(); + expect(result.turns).toHaveLength(1); + expect(result.turns[0].agent_text).toBe('Hi there'); + }); + + it('startTurn re-arms the accumulator after an interrupted turn', () => { + const acc = new CallMetricsAccumulator({ + callId: 'race2', + providerMode: 'pipeline', + telephonyProvider: 'twilio', + }); + + acc.startTurn(); + acc.recordSttComplete('Hello'); + acc.recordTurnInterrupted(); + expect(acc.recordTurnComplete('dropped')).toBeNull(); + + // New turn begins. + acc.startTurn(); + acc.recordSttComplete('Second turn'); + const completed = acc.recordTurnComplete('Reply'); + expect(completed).not.toBeNull(); + expect(completed!.user_text).toBe('Second turn'); + expect(completed!.agent_text).toBe('Reply'); + }); + it('computes cost for pipeline mode', () => { const acc = new CallMetricsAccumulator({ callId: 'c4', diff --git a/libraries/typescript/tests/unit/openai-realtime.test.ts b/libraries/typescript/tests/unit/openai-realtime.test.ts index 40c3d928..22c6da9c 100644 --- a/libraries/typescript/tests/unit/openai-realtime.test.ts +++ b/libraries/typescript/tests/unit/openai-realtime.test.ts @@ -394,13 +394,31 @@ describe('OpenAIRealtimeAdapter (deep)', () => { const ws = await connectAdapter(adapter); ws.send.mockClear(); + // ``cancelResponse`` is now a no-op when no response item is in + // flight (eliminates the ``response_cancel_not_active`` log spam + // every phantom VAD ``speech_started`` triggered before 0.6.2). + // Simulate an in-flight assistant item so the cancel path runs. + (adapter as unknown as { currentResponseItemId: string | null }) + .currentResponseItemId = 'msg_test_001'; + adapter.cancelResponse(); - expect(ws.send).toHaveBeenCalledOnce(); - const sent = JSON.parse(ws.send.mock.calls[0][0] as string); + // truncate + cancel; ``response.cancel`` is the last frame. + const lastCall = ws.send.mock.calls[ws.send.mock.calls.length - 1]; + const sent = JSON.parse(lastCall[0] as string); expect(sent.type).toBe('response.cancel'); }); + it('is a no-op when no response item is in flight', async () => { + const adapter = new OpenAIRealtimeAdapter('sk-test'); + const ws = await connectAdapter(adapter); + ws.send.mockClear(); + + adapter.cancelResponse(); + + expect(ws.send).not.toHaveBeenCalled(); + }); + it('does not throw when not connected', () => { const adapter = new OpenAIRealtimeAdapter('sk-test'); expect(() => adapter.cancelResponse()).not.toThrow(); diff --git a/libraries/typescript/tests/unit/prewarm.test.ts b/libraries/typescript/tests/unit/prewarm.test.ts index 5d5c8096..a4d04453 100644 --- a/libraries/typescript/tests/unit/prewarm.test.ts +++ b/libraries/typescript/tests/unit/prewarm.test.ts @@ -96,7 +96,45 @@ describe('[unit] prewarm — Agent flag defaults', () => { expect(agent.prewarmFirstMessage).toBeUndefined(); // Default behaviour: prewarm is on unless user explicitly set false. expect(agent.prewarm !== false).toBe(true); - expect(Boolean(agent.prewarmFirstMessage)).toBe(false); + }); + + it('phone.agent() leaves prewarmFirstMessage undefined in pipeline mode (opt-in)', () => { + // Default-on was reverted on 2026-05-19 after the 0.6.2 acceptance + // run showed a phantom-barge-in interaction: the prewarm burst at + // pickup tripped Silero VAD on the very first inbound frame and the + // firstMessage was cancelled mid-playback. Pipeline mode now leaves + // the flag opt-in; callers wanting the prewarm path set it explicitly. + const phone = makePatter(); + const stt = new StubSTT(); + const tts = new StubTTS(); + const llm = new StubLLM(); + const agent = phone.agent({ systemPrompt: 'hi', stt, tts, llm }); + expect(agent.provider).toBe('pipeline'); + expect(agent.prewarmFirstMessage).toBeUndefined(); + }); + + it('phone.agent() does NOT default prewarmFirstMessage in realtime mode', () => { + // Realtime / ConvAI handlers never consume the prewarm cache; setting + // the flag would only waste TTS spend, so the default stays off when + // the caller didn't explicitly pick pipeline. + const phone = makePatter(); + const agent = phone.agent({ systemPrompt: 'hi', provider: 'openai_realtime' }); + expect(agent.prewarmFirstMessage).toBeUndefined(); + }); + + it('phone.agent() preserves explicit prewarmFirstMessage=false in pipeline mode (opt-out)', () => { + const phone = makePatter(); + const stt = new StubSTT(); + const tts = new StubTTS(); + const llm = new StubLLM(); + const agent = phone.agent({ + systemPrompt: 'hi', + stt, + tts, + llm, + prewarmFirstMessage: false, + }); + expect(agent.prewarmFirstMessage).toBe(false); }); }); diff --git a/libraries/typescript/tests/unit/stream-handler.test.ts b/libraries/typescript/tests/unit/stream-handler.test.ts index 9a3789fb..67ee2ec2 100644 --- a/libraries/typescript/tests/unit/stream-handler.test.ts +++ b/libraries/typescript/tests/unit/stream-handler.test.ts @@ -437,35 +437,38 @@ describe('StreamHandler', () => { }); // ----------------------------------------------------------------------- - // AEC OFF (default — PSTN deployments). Gate is 100 ms. + // AEC OFF (default — PSTN deployments). Gate is 500 ms (raised 100 → + // 500 on 2026-05-19 after the 0.6.2 acceptance run showed phantom VAD + // ``speech_start`` events firing within the first ~250 ms of the + // prewarmed firstMessage and cancelling it). // ----------------------------------------------------------------------- describe('AEC off (PSTN default)', () => { - it('canBargeIn() false within 100 ms anti-flicker window', () => { + it('canBargeIn() false within 500 ms anti-flicker window', () => { const h = new StreamHandler(makeDeps(), makeMockWs(), '+15551111111', '+15552222222'); const p = priv(h); p.aec = null; - p.speakingStartedAt = Date.now() - 50; - p.firstAudioSentAt = Date.now() - 50; // 50 ms — still inside 100 ms gate + p.speakingStartedAt = Date.now() - 250; + p.firstAudioSentAt = Date.now() - 250; // 250 ms — still inside 500 ms gate expect(p.canBargeIn()).toBe(false); }); - it('canBargeIn() true past 100 ms (well below the 1 s AEC gate)', () => { + it('canBargeIn() true past 500 ms (well below the 1 s AEC gate)', () => { const h = new StreamHandler(makeDeps(), makeMockWs(), '+15551111111', '+15552222222'); const p = priv(h); p.aec = null; - p.speakingStartedAt = Date.now() - 200; - p.firstAudioSentAt = Date.now() - 200; // 200 ms — past 100 ms gate, under 1 s + p.speakingStartedAt = Date.now() - 700; + p.firstAudioSentAt = Date.now() - 700; // 700 ms — past 500 ms gate, under 1 s expect(p.canBargeIn()).toBe(true); }); - it('handleBargeIn fires after 400 ms with AEC off (the bug fix)', () => { + it('handleBargeIn fires after 600 ms with AEC off (the bug fix)', () => { // Pre-fix this would have been suppressed by the hardcoded 1 s gate. const h = new StreamHandler(makeDeps(), makeMockWs(), '+15551111111', '+15552222222'); const p = priv(h); p.aec = null; p.isSpeaking = true; - p.speakingStartedAt = Date.now() - 400; - p.firstAudioSentAt = Date.now() - 400; + p.speakingStartedAt = Date.now() - 600; + p.firstAudioSentAt = Date.now() - 600; const result = p.handleBargeIn({ text: 'stop' }); expect(result).toBe(true); expect(p.isSpeaking).toBe(false); @@ -538,7 +541,16 @@ describe('StreamHandler', () => { // chunks are unconfirmed. ``cancelSpeaking`` drains every pending mark // so the waiting loop exits on the next tick. // ------------------------------------------------------------------------- - describe('firstMessage mark-gated pacing', () => { + // SKIPPED 2026-05-22: mark-gated per-chunk pacing was replaced with a + // burst-deliver model in commit 5574997 + // (``fix(prewarm): burst-deliver prewarmed first-message bytes, drop the + // slow per-chunk sleep``). ``sendPacedFirstMessageBytes`` / + // ``firstMessageMarkCounter`` / ``sendMarkAwaitable`` no longer exist as + // public surface. Left as ``describe.skip`` to preserve the historical + // intent — the regression these tests pinned (audio buffered past + // barge-in on the WS edge) is now covered by the burst-deliver path's + // own ``cancelActiveStream`` plumbing. + describe.skip('firstMessage mark-gated pacing', () => { interface FmPriv { isSpeaking: boolean; speakingStartedAt: number | null; @@ -702,7 +714,10 @@ describe('StreamHandler', () => { }); }); - describe('cleanup drains pending firstMessage marks', () => { + // SKIPPED 2026-05-22: see note on the ``firstMessage mark-gated pacing`` + // block above — burst-deliver replaced the mark-window plumbing; pending + // marks no longer exist to drain. + describe.skip('cleanup drains pending firstMessage marks', () => { interface CleanupPriv { isSpeaking: boolean; speakingStartedAt: number | null; @@ -777,7 +792,10 @@ describe('StreamHandler', () => { }); }); - describe('firstMessage mark counter resets across sends + on cleanup', () => { + // SKIPPED 2026-05-22: see note on the ``firstMessage mark-gated pacing`` + // block above — burst-deliver replaced the mark-counter plumbing; + // ``firstMessageMarkCounter`` no longer exists. + describe.skip('firstMessage mark counter resets across sends + on cleanup', () => { interface CounterPriv { isSpeaking: boolean; speakingStartedAt: number | null;