getpatter 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,7 +57,8 @@ await phone.serve({ agent, tunnel: true });
57
57
  | Tool calling | `agent({ tools: [tool(...)] })` | Agent calls external APIs mid-conversation |
58
58
  | Custom STT + TTS | `agent({ stt: new DeepgramSTT(), tts: new ElevenLabsTTS() })` | Bring your own voice providers |
59
59
  | Dynamic variables | `agent({ variables: {...} })` | Personalize prompts per caller |
60
- | Custom LLM (any model) | `serve({ onMessage })` | Claude, Mistral, LLaMA, etc. |
60
+ | Pluggable LLM | `agent({ llm: new AnthropicLLM() })` | 5 built-in providers: OpenAI, Anthropic, Groq, Cerebras, Google |
61
+ | Custom LLM (any model) | `serve({ onMessage })` | Route to anything — local inference, internal gateways, etc. |
61
62
  | Call recording | `serve({ recording: true })` | Record all calls |
62
63
  | Call transfer | `transfer_call` (auto-injected) | Transfer to a human |
63
64
  | Voicemail drop | `call({ voicemailMessage: "..." })` | Play message on voicemail |
@@ -80,6 +81,10 @@ Every provider reads its credentials from the environment by default. Pass `apiK
80
81
  | `LMNT_API_KEY` | `LMNTTTS` |
81
82
  | `SONIOX_API_KEY` | `SonioxSTT` |
82
83
  | `ASSEMBLYAI_API_KEY` | `AssemblyAISTT` |
84
+ | `ANTHROPIC_API_KEY` | `AnthropicLLM` |
85
+ | `GROQ_API_KEY` | `GroqLLM` |
86
+ | `CEREBRAS_API_KEY` | `CerebrasLLM` |
87
+ | `GEMINI_API_KEY` (or `GOOGLE_API_KEY`) | `GoogleLLM` |
83
88
 
84
89
  ```bash
85
90
  cp .env.example .env
@@ -180,6 +185,8 @@ import {
180
185
  DeepgramSTT, WhisperSTT, CartesiaSTT, SonioxSTT, AssemblyAISTT,
181
186
  // TTS
182
187
  ElevenLabsTTS, OpenAITTS, CartesiaTTS, RimeTTS, LMNTTTS,
188
+ // LLM
189
+ OpenAILLM, AnthropicLLM, GroqLLM, CerebrasLLM, GoogleLLM,
183
190
  // Tunnels
184
191
  CloudflareTunnel, StaticTunnel,
185
192
  // Primitives
@@ -225,6 +232,23 @@ const agent = phone.agent({
225
232
  await phone.serve({ agent, tunnel: true });
226
233
  ```
227
234
 
235
+ ### Pipeline mode — pick STT, LLM, TTS independently
236
+
237
+ ```typescript
238
+ import { Patter, Twilio, DeepgramSTT, AnthropicLLM, ElevenLabsTTS } from "getpatter";
239
+
240
+ const phone = new Patter({ carrier: new Twilio(), phoneNumber: "+15550001234" });
241
+ const agent = phone.agent({
242
+ stt: new DeepgramSTT(), // reads DEEPGRAM_API_KEY
243
+ llm: new AnthropicLLM(), // reads ANTHROPIC_API_KEY
244
+ tts: new ElevenLabsTTS({ voiceId: "rachel" }), // reads ELEVENLABS_API_KEY
245
+ systemPrompt: "You are a helpful voice assistant.",
246
+ });
247
+ await phone.serve({ agent, tunnel: true });
248
+ ```
249
+
250
+ Available LLM providers: `OpenAILLM`, `AnthropicLLM`, `GroqLLM`, `CerebrasLLM`, `GoogleLLM`. Tool calling works across all five. For fully custom logic, drop `llm` and pass an `onMessage` callback to `serve()` instead.
251
+
228
252
  ### Tool calling
229
253
 
230
254
  ```typescript
@@ -7,7 +7,7 @@ var log = getLogger();
7
7
  async function startTunnel(port, timeoutMs = 3e4) {
8
8
  let tunnelMod;
9
9
  try {
10
- tunnelMod = await import("./lib-4WCAS54J.mjs");
10
+ tunnelMod = await import("cloudflared");
11
11
  } catch {
12
12
  throw new Error(
13
13
  'Built-in tunnel requires the "cloudflared" package. Install it with:\n\n npm install cloudflared\n\nOr provide your own webhookUrl instead of using tunnel: true.'
@@ -2351,7 +2351,7 @@ var StreamHandler = class {
2351
2351
  ttsProvider: ttsProviderName,
2352
2352
  pricing: deps.pricing
2353
2353
  });
2354
- getLogger().info(`WebSocket connection opened (${deps.bridge.label})`);
2354
+ getLogger().debug(`WebSocket connection opened (${deps.bridge.label})`);
2355
2355
  }
2356
2356
  // ---------------------------------------------------------------------------
2357
2357
  // Public: called by the provider-specific parsers in server.ts
@@ -2367,9 +2367,12 @@ var StreamHandler = class {
2367
2367
  this.metricsAcc.callId = callId;
2368
2368
  if (customParams.caller && !this.caller) this.caller = customParams.caller;
2369
2369
  if (customParams.callee && !this.callee) this.callee = customParams.callee;
2370
- getLogger().info(`Call started: ${callId}`);
2370
+ const mode = this.deps.agent.engine ? `engine=${this.deps.agent.engine.kind ?? "unknown"}` : "pipeline";
2371
+ getLogger().info(
2372
+ `Call started: ${callId} (${this.deps.bridge.label}, ${mode}, ${sanitizeLogValue(this.caller || "?")} \u2192 ${sanitizeLogValue(this.callee || "?")})`
2373
+ );
2371
2374
  if (Object.keys(customParams).length > 0) {
2372
- getLogger().info(`Custom params: ${sanitizeLogValue(JSON.stringify(customParams))}`);
2375
+ getLogger().debug(`Custom params: ${sanitizeLogValue(JSON.stringify(customParams))}`);
2373
2376
  }
2374
2377
  this.deps.metricsStore.recordCallStart({
2375
2378
  call_id: callId,
@@ -2417,7 +2420,7 @@ var StreamHandler = class {
2417
2420
  }
2418
2421
  });
2419
2422
  if (recResp.ok) {
2420
- getLogger().info(`Recording started for ${callId}`);
2423
+ getLogger().debug(`Recording started for ${callId}`);
2421
2424
  } else {
2422
2425
  getLogger().warn(`could not start recording: ${await recResp.text()}`);
2423
2426
  }
@@ -2472,7 +2475,7 @@ var StreamHandler = class {
2472
2475
  }
2473
2476
  /** Handle a DTMF keypress event (Twilio only). */
2474
2477
  async handleDtmf(digit) {
2475
- getLogger().info(`DTMF: ${digit}`);
2478
+ getLogger().debug(`DTMF: ${digit}`);
2476
2479
  if (this.adapter instanceof OpenAIRealtimeAdapter) {
2477
2480
  await this.adapter.sendText(`The user pressed key ${digit} on their phone keypad.`);
2478
2481
  }
@@ -2534,14 +2537,14 @@ var StreamHandler = class {
2534
2537
  this.stt = await this.deps.bridge.createStt(this.deps.agent);
2535
2538
  this.tts = await createTTS(this.deps.agent);
2536
2539
  if (!this.stt) {
2537
- getLogger().info(`Pipeline mode (${label}): no STT configured`);
2540
+ getLogger().debug(`Pipeline mode (${label}): no STT configured`);
2538
2541
  }
2539
2542
  if (!this.tts) {
2540
- getLogger().info(`Pipeline mode (${label}): no TTS configured`);
2543
+ getLogger().debug(`Pipeline mode (${label}): no TTS configured`);
2541
2544
  }
2542
2545
  try {
2543
2546
  if (this.stt) await this.stt.connect();
2544
- getLogger().info(`Pipeline mode (${label}): STT + TTS connected`);
2547
+ getLogger().debug(`Pipeline mode (${label}): STT + TTS connected`);
2545
2548
  } catch (e) {
2546
2549
  getLogger().error(`Pipeline connect FAILED (${label}):`, e);
2547
2550
  try {
@@ -2574,7 +2577,24 @@ var StreamHandler = class {
2574
2577
  this.history.push({ role: "assistant", text: this.deps.agent.firstMessage, timestamp: Date.now() });
2575
2578
  }
2576
2579
  }
2577
- if (!this.deps.onMessage && this.deps.config.openaiKey) {
2580
+ if (this.deps.agent.llm) {
2581
+ if (this.deps.onMessage) {
2582
+ throw new Error(
2583
+ "Cannot pass both agent({ llm }) and serve({ onMessage }). Pick one \u2014 `llm` for built-in LLMs, `onMessage` for custom logic."
2584
+ );
2585
+ }
2586
+ this.llmLoop = new LLMLoop(
2587
+ "",
2588
+ // apiKey unused when llmProvider is supplied
2589
+ "",
2590
+ // model unused when llmProvider is supplied
2591
+ resolvedPrompt,
2592
+ this.deps.agent.tools,
2593
+ this.deps.agent.llm
2594
+ );
2595
+ const llmLabel = this.deps.agent.llm.constructor?.name ?? "custom";
2596
+ getLogger().debug(`Built-in LLM loop active (pipeline, ${label}, llm=${llmLabel})`);
2597
+ } else if (!this.deps.onMessage && this.deps.config.openaiKey) {
2578
2598
  let llmModel = this.deps.agent.model || "gpt-4o-mini";
2579
2599
  if (llmModel.includes("realtime")) llmModel = "gpt-4o-mini";
2580
2600
  this.llmLoop = new LLMLoop(
@@ -2583,7 +2603,7 @@ var StreamHandler = class {
2583
2603
  resolvedPrompt,
2584
2604
  this.deps.agent.tools
2585
2605
  );
2586
- getLogger().info(`Built-in LLM loop active (pipeline, ${label})`);
2606
+ getLogger().debug(`Built-in LLM loop active (pipeline, ${label})`);
2587
2607
  }
2588
2608
  if (this.stt) {
2589
2609
  this.stt.onTranscript(async (transcript) => {
@@ -2644,7 +2664,7 @@ var StreamHandler = class {
2644
2664
  }
2645
2665
  async processTranscript(transcript) {
2646
2666
  if (transcript.text && this.isSpeaking) {
2647
- getLogger().info(
2667
+ getLogger().debug(
2648
2668
  `Barge-in: caller spoke over agent (${sanitizeLogValue(transcript.text.slice(0, 40))})`
2649
2669
  );
2650
2670
  this.isSpeaking = false;
@@ -2679,17 +2699,17 @@ var StreamHandler = class {
2679
2699
  "cool"
2680
2700
  ]);
2681
2701
  if (HALLUCINATIONS.has(stripped) || stripped === "") {
2682
- getLogger().info(`Dropped likely STT hallucination: ${sanitizeLogValue(normalised.slice(0, 40))}`);
2702
+ getLogger().debug(`Dropped likely STT hallucination: ${sanitizeLogValue(normalised.slice(0, 40))}`);
2683
2703
  return;
2684
2704
  }
2685
2705
  if (sinceLastMs < 2e3 && normalised === this.lastCommitText) {
2686
- getLogger().info(
2706
+ getLogger().debug(
2687
2707
  `Dropped duplicate final transcript (${(sinceLastMs / 1e3).toFixed(1)}s since last): ${sanitizeLogValue(normalised.slice(0, 40))}`
2688
2708
  );
2689
2709
  return;
2690
2710
  }
2691
2711
  if (sinceLastMs < 500) {
2692
- getLogger().info(
2712
+ getLogger().debug(
2693
2713
  `Dropped back-to-back final transcript (${(sinceLastMs / 1e3).toFixed(2)}s since last): ${sanitizeLogValue(normalised.slice(0, 40))}`
2694
2714
  );
2695
2715
  return;
@@ -2697,7 +2717,7 @@ var StreamHandler = class {
2697
2717
  this.lastCommitText = normalised;
2698
2718
  this.lastCommitAt = now;
2699
2719
  const label = this.deps.bridge.label;
2700
- getLogger().info(`User (${label} pipeline): ${sanitizeLogValue(transcript.text)}`);
2720
+ getLogger().debug(`User (${label} pipeline): ${sanitizeLogValue(transcript.text)}`);
2701
2721
  this.metricsAcc.startTurn();
2702
2722
  this.metricsAcc.recordSttComplete(transcript.text);
2703
2723
  if (this.deps.onTranscript) {
@@ -2712,7 +2732,7 @@ var StreamHandler = class {
2712
2732
  const hookCtx = this.buildHookContext();
2713
2733
  const filteredTranscript = await hookExecutor.runAfterTranscribe(transcript.text, hookCtx);
2714
2734
  if (filteredTranscript === null) {
2715
- getLogger().info(`afterTranscribe hook vetoed turn (${label})`);
2735
+ getLogger().debug(`afterTranscribe hook vetoed turn (${label})`);
2716
2736
  this.metricsAcc.recordTurnInterrupted();
2717
2737
  return;
2718
2738
  }
@@ -2800,7 +2820,7 @@ var StreamHandler = class {
2800
2820
  if (!this.llmLoop) {
2801
2821
  const guard = checkGuardrails(responseText, this.deps.agent.guardrails);
2802
2822
  if (guard) {
2803
- getLogger().info(`Guardrail '${guard.name}' triggered (pipeline)`);
2823
+ getLogger().debug(`Guardrail '${guard.name}' triggered (pipeline)`);
2804
2824
  responseText = guard.replacement ?? "I'm sorry, I can't respond to that.";
2805
2825
  }
2806
2826
  this.metricsAcc.recordLlmComplete();
@@ -2878,7 +2898,7 @@ var StreamHandler = class {
2878
2898
  this.adapter = this.deps.buildAIAdapter(resolvedPrompt);
2879
2899
  try {
2880
2900
  await this.adapter.connect();
2881
- getLogger().info(`AI adapter connected (${label})`);
2901
+ getLogger().debug(`AI adapter connected (${label})`);
2882
2902
  } catch (e) {
2883
2903
  getLogger().error(`AI adapter connect FAILED (${label}):`, e);
2884
2904
  try {
@@ -2920,7 +2940,7 @@ var StreamHandler = class {
2920
2940
  this.deps.bridge.sendMark(this.ws, `audio_${this.chunkCount}`, this.streamSid);
2921
2941
  } else if (type === "transcript_input") {
2922
2942
  const inputText = eventData;
2923
- getLogger().info(`User (${this.deps.bridge.label}): ${sanitizeLogValue(inputText)}`);
2943
+ getLogger().debug(`User (${this.deps.bridge.label}): ${sanitizeLogValue(inputText)}`);
2924
2944
  this.history.push({ role: "user", text: inputText, timestamp: Date.now() });
2925
2945
  this.metricsAcc.startTurn();
2926
2946
  this.currentAgentText = "";
@@ -2938,7 +2958,7 @@ var StreamHandler = class {
2938
2958
  if (outputText) {
2939
2959
  const triggered = checkGuardrails(outputText, this.deps.agent.guardrails);
2940
2960
  if (triggered) {
2941
- getLogger().info(`Guardrail '${triggered.name}' triggered`);
2961
+ getLogger().debug(`Guardrail '${triggered.name}' triggered`);
2942
2962
  if (this.adapter instanceof OpenAIRealtimeAdapter) {
2943
2963
  this.adapter.cancelResponse();
2944
2964
  await this.adapter.sendText(triggered.replacement ?? "I'm sorry, I can't respond to that.");
@@ -2997,7 +3017,7 @@ var StreamHandler = class {
2997
3017
  await adapter.sendFunctionResult(fc.call_id, JSON.stringify({ error: "Invalid phone number format", status: "rejected" }));
2998
3018
  return;
2999
3019
  }
3000
- getLogger().info(`Transferring call to ${transferTo}`);
3020
+ getLogger().debug(`Transferring call to ${transferTo}`);
3001
3021
  await adapter.sendFunctionResult(fc.call_id, JSON.stringify({ status: "transferring", to: transferTo }));
3002
3022
  await this.deps.bridge.transferCall(this.callId, transferTo);
3003
3023
  if (this.deps.onTranscript) {
@@ -3013,7 +3033,7 @@ var StreamHandler = class {
3013
3033
  endArgs = {};
3014
3034
  }
3015
3035
  const reason = endArgs.reason ?? "conversation_complete";
3016
- getLogger().info(`Ending call (${this.deps.bridge.label}): ${reason}`);
3036
+ getLogger().debug(`Ending call (${this.deps.bridge.label}): ${reason}`);
3017
3037
  await adapter.sendFunctionResult(fc.call_id, JSON.stringify({ status: "ending", reason }));
3018
3038
  await this.deps.bridge.endCall(this.callId, this.ws);
3019
3039
  if (this.deps.onTranscript) {
@@ -3065,6 +3085,11 @@ var StreamHandler = class {
3065
3085
  transcript: [...this.history.entries],
3066
3086
  metrics: finalMetrics
3067
3087
  };
3088
+ const cost = finalMetrics.cost?.total ?? 0;
3089
+ const latencyP95 = finalMetrics.latency_p95?.total_ms ?? 0;
3090
+ getLogger().info(
3091
+ `Call ended: ${this.callId} (${finalMetrics.duration_seconds.toFixed(1)}s, ${finalMetrics.turns.length} turns, cost=$${cost.toFixed(4)}, p95=${Math.round(latencyP95)}ms)`
3092
+ );
3068
3093
  this.deps.metricsStore.recordCallEnd(
3069
3094
  callEndData,
3070
3095
  finalMetrics
@@ -3101,7 +3126,7 @@ async function queryDeepgramCost(metricsAcc, deepgramKey, deepgramRequestId) {
3101
3126
  const usd = reqData.response?.details?.usd;
3102
3127
  if (usd != null) {
3103
3128
  metricsAcc.setActualSttCost(usd);
3104
- getLogger().info(`Deepgram actual cost: $${usd}`);
3129
+ getLogger().debug(`Deepgram actual cost: $${usd}`);
3105
3130
  }
3106
3131
  }
3107
3132
  }