pinecall-runtime 0.1.6__py3-none-any.whl

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.
Files changed (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
pinecall/log/reduce.py ADDED
@@ -0,0 +1,659 @@
1
+ """The reducer: a log's entries folded into State; and the usage and latencies a log counts."""
2
+
3
+ import logging
4
+ from collections.abc import Iterable, Mapping
5
+ from dataclasses import dataclass, field, replace
6
+ from statistics import median
7
+ from typing import Self
8
+
9
+ from pinecall.domain.call import PhoneLeg
10
+ from pinecall.domain.errors import DeclarationRefused
11
+ from pinecall.wire.events import (
12
+ AgentRegistered,
13
+ AgentStateChanged,
14
+ AgentTranscript,
15
+ AgentTurnEnded,
16
+ AttentionAnswered,
17
+ AttentionRequested,
18
+ CallDialing,
19
+ CallEnded,
20
+ CallLine,
21
+ CallRinging,
22
+ CallStarted,
23
+ CallSummary,
24
+ CallTransferred,
25
+ ConfirmDeclined,
26
+ ConfirmGranted,
27
+ ConfirmRequest,
28
+ Custom,
29
+ DocsSources,
30
+ ErrorEvent,
31
+ EventReceived,
32
+ LogGap,
33
+ MemoryOps,
34
+ PromptChanged,
35
+ StateChanged,
36
+ SupervisorReleased,
37
+ SupervisorTookOver,
38
+ SupervisorTransferred,
39
+ ToolCall,
40
+ ToolsChanged,
41
+ UserStateChanged,
42
+ UserTranscript,
43
+ UserTurnEnded,
44
+ event_of,
45
+ )
46
+ from pinecall.wire.frames import Entry, WireModel
47
+ from pinecall.wire.metrics import (
48
+ AvatarMetrics,
49
+ EOTInferenceMetrics,
50
+ EOUMetrics,
51
+ InterruptionMetrics,
52
+ LLMMetrics,
53
+ LLMModelUsage,
54
+ RealtimeModelMetrics,
55
+ STTMetrics,
56
+ TTSMetrics,
57
+ TTSModelUsage,
58
+ VADMetrics,
59
+ )
60
+ from pinecall.wire.parts import ToolResult
61
+ from pinecall.wire.room import ParticipantJoined, ParticipantLeft, ParticipantSpeaking, RoomOpened
62
+ from pinecall.wire.scores import CallScore
63
+ from pinecall.wire.state import (
64
+ AgentTurn,
65
+ AttentionState,
66
+ CollectedMetrics,
67
+ Confirm,
68
+ CustomNote,
69
+ Gap,
70
+ Handoff,
71
+ LiveTranscript,
72
+ LoggedError,
73
+ Participant,
74
+ PromptBlockState,
75
+ ReceivedEvent,
76
+ Room,
77
+ State,
78
+ ToolRun,
79
+ TransferState,
80
+ Turn,
81
+ UserTurn,
82
+ )
83
+
84
+ logger = logging.getLogger(__name__)
85
+
86
+
87
+ UNREADABLE = "unreadable"
88
+
89
+
90
+ NOT_THIS_SHAPE = "{type} at seq {seq} is not the shape this reader knows: {why}"
91
+
92
+
93
+ # The only metered types. Usage is a projection of the log, never stored beside it.
94
+ METERED_TYPES = ("call.summary", "call.score")
95
+
96
+
97
+ UNOWNED = "unowned"
98
+
99
+
100
+ # livekit's names, in the order they happen within a turn, then two of ours: dead air is the
101
+ # silence from the caller stopping to the agent starting, one per reply that followed the caller;
102
+ # talk share is the agent's part of the time anybody spoke, one per call, a fraction and no seconds.
103
+ MEASURES = (
104
+ "transcription_delay",
105
+ "end_of_turn_delay",
106
+ "llm_node_ttft",
107
+ "tts_node_ttfb",
108
+ "e2e_latency",
109
+ "dead_air",
110
+ "talk_share",
111
+ )
112
+
113
+
114
+ A_MINUTE_S = 60.0
115
+
116
+ # livekit-sip's attributes on a participant that is a phone leg.
117
+ SIP_CALL = "sip.callID"
118
+ # Only a leg that came in through a dispatch rule carries one.
119
+ SIP_RULE = "sip.ruleID"
120
+ TWILIO_CALL = "sip.twilio.callSid"
121
+ OWN_NUMBER = "sip.trunkPhoneNumber"
122
+ FAR_NUMBER = "sip.phoneNumber"
123
+
124
+
125
+ # position is the store's row order across every log; the meter resumes from it.
126
+ @dataclass(frozen=True, slots=True)
127
+ class Metered:
128
+ """A metered entry with its place in the whole store and the org its log belongs to."""
129
+
130
+ position: int
131
+ org: str | None
132
+ entry: Entry
133
+
134
+
135
+ # cost_usd is the vendors' estimate, what the call cost the operator, never what is charged.
136
+ @dataclass(frozen=True, slots=True)
137
+ class Usage:
138
+ """What was consumed: calls, minutes, turns, tokens, characters, judge calls, the cost."""
139
+
140
+ calls: int = 0
141
+ minutes: float = 0.0
142
+ messages: int = 0
143
+ input_tokens: int = 0
144
+ output_tokens: int = 0
145
+ characters: int = 0
146
+ judge_calls: int = 0
147
+ cost_usd: float = 0.0
148
+
149
+ def __add__(self, other: Self) -> Self:
150
+ """Return the two added up, field by field."""
151
+ return type(self)(
152
+ calls=self.calls + other.calls,
153
+ minutes=self.minutes + other.minutes,
154
+ messages=self.messages + other.messages,
155
+ input_tokens=self.input_tokens + other.input_tokens,
156
+ output_tokens=self.output_tokens + other.output_tokens,
157
+ characters=self.characters + other.characters,
158
+ judge_calls=self.judge_calls + other.judge_calls,
159
+ cost_usd=self.cost_usd + other.cost_usd,
160
+ )
161
+
162
+
163
+ @dataclass(frozen=True, slots=True)
164
+ class UsageRow:
165
+ """What one metered entry consumed, and whose it was."""
166
+
167
+ cursor: int
168
+ org: str
169
+ agent: str
170
+ call: str
171
+ type: str
172
+ at: float
173
+ used: Usage = field(default_factory=Usage)
174
+
175
+
176
+ @dataclass(frozen=True, slots=True)
177
+ class Median:
178
+ """A measure's median over one call, and how many turns reported it."""
179
+
180
+ name: str
181
+ seconds: float
182
+ turns: int
183
+
184
+
185
+ # Must fold as the TypeScript and Ruby reducers do: the protocol's golden log holds all three.
186
+ def reduce(entries: Iterable[Entry]) -> State:
187
+ """Fold the entries, in order, into the State of an empty log."""
188
+ state = initial_state()
189
+ for entry in entries:
190
+ state = apply(state, entry)
191
+ return state
192
+
193
+
194
+ # Built from a mapping because `from` is a Python keyword.
195
+ def initial_state() -> State:
196
+ """Return the State of a log nothing was written to."""
197
+ return State.model_validate(
198
+ {
199
+ "seq": 0,
200
+ "agent": "",
201
+ "call": None,
202
+ "status": "idle",
203
+ "channel": None,
204
+ "direction": None,
205
+ "from": None,
206
+ "to": None,
207
+ "caller": None,
208
+ "room": None,
209
+ "started_at": None,
210
+ "ended_at": None,
211
+ "end_reason": None,
212
+ "outcome": None,
213
+ "user_state": None,
214
+ "agent_state": None,
215
+ "live": {"user": None, "agent": None},
216
+ "turns": [],
217
+ "metrics": {block: [] for block in CollectedMetrics.model_fields},
218
+ "tools": [],
219
+ "app_state": {},
220
+ "events": [],
221
+ "prompt": {},
222
+ "tools_visible": [],
223
+ "confirms": [],
224
+ "memory": [],
225
+ "sources": [],
226
+ "handoff": {"active": False, "by": None},
227
+ "held": False,
228
+ "muted": False,
229
+ "transfer": None,
230
+ "attention": None,
231
+ "usage": [],
232
+ "cost": None,
233
+ "routes": [],
234
+ "gaps": [],
235
+ "errors": [],
236
+ "custom": [],
237
+ }
238
+ )
239
+
240
+
241
+ # An old log may hold a shape this version cannot read: it becomes one line of errors and the
242
+ # fold goes on, as the other two reducers do. A gap with a snapshot replaces the state whole.
243
+ def apply(state: State, entry: Entry) -> State:
244
+ """Fold one entry into the state and return the state it leaves."""
245
+ try:
246
+ data = event_of(entry)
247
+ except DeclarationRefused as why:
248
+ first_line = str(why).split("\n", 1)[0].strip()
249
+ message = NOT_THIS_SHAPE.format(type=entry.type, seq=entry.seq, why=first_line)
250
+ state.errors.append(LoggedError(seq=entry.seq, code=UNREADABLE, message=message))
251
+ else:
252
+ if isinstance(data, LogGap):
253
+ state = _resumed(state, data)
254
+ _call(state, data)
255
+ _talk(state, data)
256
+ _tools(state, entry, data)
257
+ _room(state, entry, data)
258
+ _people(state, entry, data)
259
+ _metrics(state.metrics, data)
260
+ _notes(state, entry, data)
261
+ state.seq = entry.seq
262
+ state.agent = entry.agent
263
+ if entry.call is not None:
264
+ state.call = entry.call
265
+ return state
266
+
267
+
268
+ def usage_row(metered: Metered) -> UsageRow:
269
+ """Return what a call.summary or a call.score consumed; an entry nobody can read, nothing."""
270
+ entry = metered.entry
271
+ row = UsageRow(
272
+ cursor=metered.position,
273
+ org=metered.org or UNOWNED,
274
+ agent=entry.agent,
275
+ call=entry.call or "",
276
+ type=entry.type,
277
+ at=entry.ts,
278
+ )
279
+ try:
280
+ data = event_of(entry)
281
+ except DeclarationRefused:
282
+ logger.warning("usage: %s at seq %d is unreadable", entry.type, entry.seq, exc_info=True)
283
+ return row
284
+ match data:
285
+ case CallSummary():
286
+ return replace(row, used=_used_by_a_call(data))
287
+ case CallScore():
288
+ used = Usage(judge_calls=data.judge_calls, cost_usd=data.judge_cost_usd or 0.0)
289
+ return replace(row, used=used)
290
+ case _:
291
+ return row
292
+
293
+
294
+ def totals_by_org(rows: Iterable[UsageRow]) -> dict[str, Usage]:
295
+ """Add the rows up per org, in the order each org first appears."""
296
+ totals: dict[str, Usage] = {}
297
+ for row in rows:
298
+ totals[row.org] = totals.get(row.org, Usage()) + row.used
299
+ return totals
300
+
301
+
302
+ # One call's turns: dead air pairs a reply with the caller's turn before it, and a speaker's time
303
+ # is what each of its turns says it spoke, from starting to stopping.
304
+ def samples(turns: Iterable[Turn]) -> dict[str, list[float]]:
305
+ """Return each measure's values in turn order; a measure nobody took is left out."""
306
+ found: dict[str, list[float]] = {name: [] for name in MEASURES}
307
+ spoke = {"user": 0.0, "agent": 0.0}
308
+ before: Turn | None = None
309
+ for turn in turns:
310
+ for name, value in _measured(turn):
311
+ if value is not None:
312
+ found[name].append(value)
313
+ gap = _dead_air(before, turn)
314
+ if gap is not None:
315
+ found["dead_air"].append(gap)
316
+ spoke[turn.role] += _spoken_s(turn)
317
+ before = turn
318
+ if spoke["agent"] + spoke["user"] > 0:
319
+ found["talk_share"].append(spoke["agent"] / (spoke["agent"] + spoke["user"]))
320
+ return {name: values for name, values in found.items() if values}
321
+
322
+
323
+ # The median, not the mean: one interrupted turn would move an average. Each call is measured
324
+ # on its own, so no reply is paired with the caller of another call.
325
+ def medians(calls: Iterable[Iterable[Turn]]) -> list[Median]:
326
+ """Return one Median per measure some call reported, pooled over the calls."""
327
+ pooled: dict[str, list[float]] = {name: [] for name in MEASURES}
328
+ for turns in calls:
329
+ for name, values in samples(turns).items():
330
+ pooled[name] += values
331
+ return [
332
+ Median(name=name, seconds=median(values), turns=len(values))
333
+ for name, values in pooled.items()
334
+ if values
335
+ ]
336
+
337
+
338
+ # How long a barge-in takes to be obeyed: from the caller starting to speak over the agent to the
339
+ # agent leaving `speaking`, on each reply written as interrupted. livekit writes the reply and the
340
+ # state change in either order, so each waits for the other.
341
+ def interruption_delays(entries: Iterable[Entry]) -> list[float]:
342
+ """Seconds from the caller cutting in to the agent falling quiet, per interrupted reply."""
343
+ delays: list[float] = []
344
+ speaking = False
345
+ cut_in: float | None = None
346
+ quiet_after: float | None = None
347
+ interrupted = False
348
+ for entry in entries:
349
+ match entry.type:
350
+ case "user.state" if entry.data.get("state") == "speaking":
351
+ if speaking and cut_in is None:
352
+ cut_in = entry.ts
353
+ case "turn.agent":
354
+ interrupted = entry.data.get("interrupted") is True
355
+ case "agent.state":
356
+ now_speaking = entry.data.get("state") == "speaking"
357
+ if speaking and not now_speaking and cut_in is not None:
358
+ quiet_after = entry.ts - cut_in
359
+ if now_speaking and not speaking:
360
+ cut_in, quiet_after, interrupted = None, None, False
361
+ speaking = now_speaking
362
+ case _:
363
+ continue
364
+ if interrupted and quiet_after is not None:
365
+ delays.append(quiet_after)
366
+ cut_in, quiet_after, interrupted = None, None, False
367
+ return delays
368
+
369
+
370
+ # A leg still up when the log ends is up until the call's last entry.
371
+ def phone_legs(entries: Iterable[Entry]) -> list[PhoneLeg]:
372
+ """Each leg of the call on the phone network, from when it joined the room until it left."""
373
+ joined: dict[str, tuple[float, PhoneLeg]] = {}
374
+ legs: list[PhoneLeg] = []
375
+ last = 0.0
376
+ for entry in entries:
377
+ last = entry.ts
378
+ if entry.type == "participant.joined":
379
+ arrived = ParticipantJoined.model_validate(entry.data)
380
+ leg = _leg_of(arrived.attributes)
381
+ if leg is not None:
382
+ joined[arrived.identity] = (entry.ts, leg)
383
+ elif entry.type == "participant.left":
384
+ gone = ParticipantLeft.model_validate(entry.data)
385
+ if gone.identity in joined:
386
+ began, leg = joined.pop(gone.identity)
387
+ legs.append(replace(leg, seconds=entry.ts - began))
388
+ legs += [replace(leg, seconds=last - began) for began, leg in joined.values()]
389
+ return legs
390
+
391
+
392
+ def _resumed(state: State, gap: LogGap) -> State:
393
+ resumed = state if gap.snapshot is None else gap.snapshot.model_copy(deep=True)
394
+ resumed.gaps.append(Gap(from_seq=gap.from_seq, to_seq=gap.to_seq))
395
+ return resumed
396
+
397
+
398
+ def _call(state: State, data: WireModel) -> None:
399
+ match data:
400
+ case CallRinging():
401
+ state.status, state.direction = "ringing", "inbound"
402
+ _line(state, data)
403
+ case CallDialing():
404
+ state.status, state.direction = "dialing", "outbound"
405
+ _line(state, data)
406
+ case CallStarted():
407
+ state.status, state.direction, state.started_at = (
408
+ "active",
409
+ data.direction,
410
+ data.started_at,
411
+ )
412
+ _line(state, data)
413
+ case CallEnded():
414
+ state.status, state.ended_at, state.end_reason = "ended", data.ended_at, data.reason
415
+ state.live = LiveTranscript(user=None, agent=None)
416
+ if state.attention is not None and state.attention.status == "open":
417
+ state.attention = state.attention.model_copy(update={"status": "lapsed"})
418
+ case CallLine():
419
+ state.held, state.muted = data.held, data.muted
420
+ case CallSummary():
421
+ state.usage, state.cost, state.outcome = list(data.usage), data.cost, data.outcome
422
+ state.end_reason = state.end_reason or data.reason
423
+ case _:
424
+ pass
425
+
426
+
427
+ def _line(state: State, data: CallRinging | CallDialing | CallStarted) -> None:
428
+ state.channel, state.from_, state.to, state.caller = (
429
+ data.channel,
430
+ data.from_,
431
+ data.to,
432
+ data.caller,
433
+ )
434
+
435
+
436
+ # A turn keeps only the fields that were on the wire, so it is built from what was written.
437
+ def _talk(state: State, data: WireModel) -> None:
438
+ match data:
439
+ case UserStateChanged():
440
+ state.user_state = data.state
441
+ case AgentStateChanged():
442
+ state.agent_state = data.state
443
+ case UserTranscript():
444
+ state.live.user = None if data.final else data.text
445
+ case AgentTranscript():
446
+ state.live.agent = None if data.final else _joined(state.live.agent or "", data)
447
+ case UserTurnEnded():
448
+ state.turns.append(UserTurn.model_validate({"role": "user", **data.written()}))
449
+ state.live.user = None
450
+ case AgentTurnEnded():
451
+ state.turns.append(AgentTurn.model_validate({"role": "agent", **data.written()}))
452
+ state.live.agent = None
453
+ case MemoryOps():
454
+ state.memory.extend(data.ops)
455
+ case DocsSources():
456
+ state.sources = list(data.sources)
457
+ case _:
458
+ pass
459
+
460
+
461
+ # A word aligned to the audio (it has a start) comes without its space; a token brings its own.
462
+ def _joined(so_far: str, delta: AgentTranscript) -> str:
463
+ glued = not so_far or so_far[-1].isspace() or delta.text[:1].isspace() or delta.start is None
464
+ return f"{so_far}{'' if glued else ' '}{delta.text}"
465
+
466
+
467
+ def _tools(state: State, entry: Entry, data: WireModel) -> None:
468
+ match data:
469
+ case ToolCall():
470
+ run = {**data.written(), "status": "running", "seq": entry.seq}
471
+ state.tools.append(ToolRun.model_validate(run))
472
+ case ToolResult():
473
+ outcome = {k: v for k, v in data.written().items() if k not in {"call_id", "name"}}
474
+ outcome["status"] = "done" if data.error is None else "failed"
475
+ _settled(state.tools, data.call_id, outcome)
476
+ case StateChanged():
477
+ state.app_state = dict(data.state)
478
+ case PromptChanged():
479
+ state.prompt[data.name] = PromptBlockState(
480
+ hash=data.hash, chars=data.chars, seq=entry.seq
481
+ )
482
+ case ToolsChanged():
483
+ state.tools_visible = list(data.visible)
484
+ case ConfirmRequest():
485
+ params = {"tool": data.tool, "call_id": data.call_id, "audience": data.audience}
486
+ state.confirms.append(Confirm(**params, phrase=data.phrase, status="pending"))
487
+ case ConfirmGranted():
488
+ _settled(state.confirms, data.call_id, {"status": "granted", "said": data.said})
489
+ case ConfirmDeclined():
490
+ text = {} if data.said is None else {"said": data.said}
491
+ _settled(
492
+ state.confirms, data.call_id, {"status": "declined", "reason": data.reason, **text}
493
+ )
494
+ case _:
495
+ pass
496
+
497
+
498
+ # The last one with that call_id, since an app may reuse an id across turns.
499
+ def _settled[T: ToolRun | Confirm](
500
+ items: list[T], call_id: str, update: Mapping[str, object]
501
+ ) -> None:
502
+ for index in range(len(items) - 1, -1, -1):
503
+ if items[index].call_id == call_id:
504
+ items[index] = items[index].model_copy(update=update)
505
+ return
506
+
507
+
508
+ # joined_at comes from the entry's ts: the event carries no time.
509
+ def _room(state: State, entry: Entry, data: WireModel) -> None:
510
+ match data:
511
+ case RoomOpened():
512
+ state.room = Room(name=data.name, sid=data.sid, participants=[], caller=None)
513
+ case EventReceived():
514
+ fact = {k: v for k, v in data.written().items() if k != "data"}
515
+ state.events.append(ReceivedEvent.model_validate({**fact, "seq": entry.seq}))
516
+ case ParticipantJoined() if state.room is not None:
517
+ arrived = {**data.written(), "joined_at": entry.ts, "speaking": False}
518
+ state.room.participants.append(Participant.model_validate(arrived))
519
+ if data.kind == "caller":
520
+ state.room.caller = data.identity
521
+ case ParticipantLeft() if state.room is not None:
522
+ state.room.participants = [
523
+ seat for seat in state.room.participants if seat.identity != data.identity
524
+ ]
525
+ if state.room.caller == data.identity:
526
+ state.room.caller = None
527
+ case ParticipantSpeaking() if state.room is not None:
528
+ for seat in state.room.participants:
529
+ if seat.identity == data.identity:
530
+ seat.speaking = data.speaking
531
+ case _:
532
+ pass
533
+
534
+
535
+ # asked_at comes from the entry's ts: the event carries no time.
536
+ def _people(state: State, entry: Entry, data: WireModel) -> None:
537
+ match data:
538
+ case CallTransferred():
539
+ by = "agent" if state.transfer is None else state.transfer.by
540
+ status = "done" if data.ok else "failed"
541
+ state.transfer = TransferState(to=data.to, mode=data.mode, status=status, by=by)
542
+ case SupervisorTookOver():
543
+ state.handoff = Handoff(active=True, by=data.by)
544
+ case SupervisorReleased():
545
+ state.handoff = Handoff(active=False, by=None)
546
+ case SupervisorTransferred():
547
+ state.transfer = TransferState(
548
+ to=data.to, mode=data.mode, status="requested", by="supervisor"
549
+ )
550
+ case AttentionRequested():
551
+ state.attention = AttentionState(
552
+ reason=data.reason, wait_s=data.wait_s, status="open", asked_at=entry.ts, by=None
553
+ )
554
+ case AttentionAnswered() if state.attention is not None:
555
+ settled = "answered" if data.ok else "lapsed"
556
+ state.attention = state.attention.model_copy(update={"status": settled, "by": data.by})
557
+ case _:
558
+ pass
559
+
560
+
561
+ def _metrics(blocks: CollectedMetrics, data: WireModel) -> None:
562
+ match data:
563
+ case LLMMetrics():
564
+ blocks.llm.append(data)
565
+ case STTMetrics():
566
+ blocks.stt.append(data)
567
+ case TTSMetrics():
568
+ blocks.tts.append(data)
569
+ case VADMetrics():
570
+ blocks.vad.append(data)
571
+ case EOUMetrics():
572
+ blocks.eou.append(data)
573
+ case EOTInferenceMetrics():
574
+ blocks.eot.append(data)
575
+ case InterruptionMetrics():
576
+ blocks.interruption.append(data)
577
+ case RealtimeModelMetrics():
578
+ blocks.realtime.append(data)
579
+ case AvatarMetrics():
580
+ blocks.avatar.append(data)
581
+ case _:
582
+ pass
583
+
584
+
585
+ # supervisor.said, .whispered and .ended, the tracks, room.sent, call.score and call.attached
586
+ # change nothing here: what they caused arrives as turns, prompt changes and call.ended.
587
+ def _notes(state: State, entry: Entry, data: WireModel) -> None:
588
+ match data:
589
+ case AgentRegistered():
590
+ state.routes = list(data.routes)
591
+ case ErrorEvent():
592
+ state.errors.append(LoggedError(seq=entry.seq, code=data.code, message=data.message))
593
+ case Custom():
594
+ state.custom.append(CustomNote(seq=entry.seq, name=data.name, data=dict(data.data)))
595
+ case _:
596
+ pass
597
+
598
+
599
+ # Tokens are counted from the LLM rows and characters from the TTS rows; every other row's
600
+ # audio seconds are already the call's minutes.
601
+ def _used_by_a_call(summary: CallSummary) -> Usage:
602
+ llm = [row for row in summary.usage if isinstance(row, LLMModelUsage)]
603
+ tts = [row for row in summary.usage if isinstance(row, TTSModelUsage)]
604
+ return Usage(
605
+ calls=1,
606
+ minutes=summary.duration_s / A_MINUTE_S,
607
+ messages=summary.turns,
608
+ input_tokens=sum(row.input_tokens or 0 for row in llm),
609
+ output_tokens=sum(row.output_tokens or 0 for row in llm),
610
+ characters=sum(row.characters_count or 0 for row in tts),
611
+ cost_usd=summary.cost.usd,
612
+ )
613
+
614
+
615
+ def _measured(turn: Turn) -> tuple[tuple[str, float | None], ...]:
616
+ match turn:
617
+ case UserTurn():
618
+ took = turn.metrics
619
+ return (
620
+ ("transcription_delay", took.transcription_delay),
621
+ ("end_of_turn_delay", took.end_of_turn_delay),
622
+ )
623
+ case AgentTurn():
624
+ took = turn.metrics
625
+ return (
626
+ ("llm_node_ttft", took.llm_node_ttft),
627
+ ("tts_node_ttfb", took.tts_node_ttfb),
628
+ ("e2e_latency", took.e2e_latency),
629
+ )
630
+
631
+
632
+ # A reply that started before the caller stopped talked over them: that is no silence.
633
+ def _dead_air(before: Turn | None, turn: Turn) -> float | None:
634
+ if not isinstance(before, UserTurn) or not isinstance(turn, AgentTurn):
635
+ return None
636
+ stopped, started = before.metrics.stopped_speaking_at, turn.metrics.started_speaking_at
637
+ if stopped is None or started is None or started < stopped:
638
+ return None
639
+ return started - stopped
640
+
641
+
642
+ def _spoken_s(turn: Turn) -> float:
643
+ started, stopped = turn.metrics.started_speaking_at, turn.metrics.stopped_speaking_at
644
+ if started is None or stopped is None or stopped < started:
645
+ return 0.0
646
+ return stopped - started
647
+
648
+
649
+ def _leg_of(attributes: Mapping[str, object]) -> PhoneLeg | None:
650
+ if SIP_CALL not in attributes:
651
+ return None
652
+ inbound = SIP_RULE in attributes
653
+ number = attributes.get(OWN_NUMBER if inbound else FAR_NUMBER)
654
+ return PhoneLeg(
655
+ carrier="twilio" if TWILIO_CALL in attributes else "sip",
656
+ direction="inbound" if inbound else "outbound",
657
+ number=number if isinstance(number, str) else "",
658
+ seconds=0.0,
659
+ )