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
@@ -0,0 +1,400 @@
1
+ """The JWTs the gateway signs: a room token and its dispatch, a seat, a log token, a code token."""
2
+
3
+ import secrets
4
+ import time
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from datetime import timedelta
8
+ from typing import Literal
9
+
10
+ import jwt
11
+ from livekit.api import AccessToken, VideoGrants
12
+ from livekit.protocol.room import RoomConfiguration
13
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
14
+
15
+ from pinecall.domain.names import Env
16
+ from pinecall.domain.person import RoomScope
17
+ from pinecall.domain.scope import SCOPE_ATTRIBUTE, Scope
18
+ from pinecall.postgres.pool import Pool
19
+ from pinecall.tenancy.keys import (
20
+ A_SEAT,
21
+ A_SEAT_LASTS_S,
22
+ A_VISITOR,
23
+ AGENT_ATTRIBUTE,
24
+ BOUND_TO_ONE_CALL,
25
+ CODE_ATTRIBUTE,
26
+ ENV_ATTRIBUTE,
27
+ JWT_PARTS,
28
+ MINTED,
29
+ NAME_ATTRIBUTE,
30
+ PROJECTION_ATTRIBUTE,
31
+ SUBJECT_ATTRIBUTE,
32
+ THE_MICROPHONE,
33
+ Bearer,
34
+ )
35
+ from pinecall.wire.parts import Projection
36
+
37
+ # One statement on every open: the update is the spend, atomic (of two dispatches racing, one
38
+ # updates the row), and the join says what the ledger held before it. A token is spent only by the
39
+ # org, the world and the agent it was minted for; one minted before its world was kept (no env) is
40
+ # held to its org and agent.
41
+ SPEND = """
42
+ WITH spent AS (
43
+ UPDATE tokens SET spent_at = now()
44
+ WHERE call = %(call)s AND spent_at IS NULL AND org = %(org)s AND agent = %(agent)s
45
+ AND (env IS NULL OR env = %(env)s)
46
+ RETURNING call
47
+ )
48
+ SELECT (SELECT count(*) FROM spent) AS spent, tokens.call AS minted, tokens.spent_at
49
+ FROM (SELECT 1) AS probe LEFT JOIN tokens ON tokens.call = %(call)s
50
+ """
51
+
52
+
53
+ IDENTITY_BYTES = 6
54
+
55
+
56
+ type Spending = Literal["spent", "already_spent", "never_minted", "minted_elsewhere"]
57
+
58
+
59
+ ONE_VISIT_TTL_S = 60
60
+
61
+
62
+ # The header of a token the gateway signs with its own key; a LiveKit token names no key id.
63
+ OUR_KEY_ID = "pinecall"
64
+
65
+
66
+ # Longer than any call, short enough that a copied link dies the same day.
67
+ READ_TTL_S = 4 * 60 * 60
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Signer:
72
+ """The LiveKit pair a room token is signed with, and the gateway's own key for every other."""
73
+
74
+ api_key: str
75
+ secret: str
76
+ # A log token and a code token open no room, so LiveKit never reads them: they are signed with
77
+ # this key, which no worker holds, and the pair every worker holds forges none.
78
+ own: str
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class Visitor:
83
+ """Who a room token seats, until when, and the dispatch it carries."""
84
+
85
+ expires_at: float
86
+ identity: str | None = None
87
+ subject: str | None = None
88
+ name: str | None = None
89
+ # Signed inside the token, so the browser cannot change the agent, org or world it reaches.
90
+ dispatch: RoomConfiguration | None = None
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class Visit:
95
+ """What a checked room token says."""
96
+
97
+ call: str
98
+ scope: RoomScope
99
+ expires_at: float
100
+ identity: str | None = None
101
+ subject: str | None = None
102
+ name: str | None = None
103
+ projection: Projection | None = None
104
+ # A code token names its code, agent and world instead of a call; `call` is "" for it.
105
+ code: str | None = None
106
+ agent: str | None = None
107
+ env: Env | None = None
108
+
109
+
110
+ class _Room(BaseModel):
111
+ model_config = ConfigDict(frozen=True, extra="ignore")
112
+
113
+ room: str = ""
114
+
115
+
116
+ class _Answer(BaseModel):
117
+ model_config = ConfigDict(frozen=True, extra="ignore")
118
+
119
+ scope: RoomScope = Field(alias=SCOPE_ATTRIBUTE)
120
+ subject: str | None = Field(None, alias=SUBJECT_ATTRIBUTE)
121
+ name: str | None = Field(None, alias=NAME_ATTRIBUTE)
122
+ projection: Projection | None = Field(None, alias=PROJECTION_ATTRIBUTE)
123
+ code: str | None = Field(None, alias=CODE_ATTRIBUTE)
124
+ agent: str | None = Field(None, alias=AGENT_ATTRIBUTE)
125
+ env: Env | None = Field(None, alias=ENV_ATTRIBUTE)
126
+
127
+
128
+ # livekit's claims, the ones a token of ours carries; its verifier drops `exp`.
129
+ class _Claims(BaseModel):
130
+ model_config = ConfigDict(frozen=True, extra="ignore")
131
+
132
+ exp: float
133
+ sub: str | None = None
134
+ video: _Room = _Room()
135
+ attributes: _Answer
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class SupervisorSeat:
140
+ """A person's seat in a live call: the token and who it says they are."""
141
+
142
+ call: str
143
+ identity: str
144
+ token: str
145
+ subject: str | None
146
+ name: str | None
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class MintedToken:
151
+ """A room token as the ledger keeps it: one call, spent once."""
152
+
153
+ call: str
154
+ org: str
155
+ env: Env
156
+ agent: str
157
+ scope: RoomScope
158
+ expires_at: float
159
+
160
+
161
+ @dataclass(frozen=True, kw_only=True)
162
+ class Grant:
163
+ """What a room token of one scope may do."""
164
+
165
+ connects: bool
166
+ audio: bool
167
+ reads_log: bool
168
+ own_call_only: bool
169
+ ttl_s: int | None
170
+ # `audio` publishes and subscribes; `hears` only subscribes; `hidden` is not seen in the room;
171
+ # `writes` sends on the call's data channel, which the agent reads as the caller's words.
172
+ hears: bool = False
173
+ hidden: bool = False
174
+ writes: bool = False
175
+
176
+
177
+ GRANTS: Mapping[RoomScope, Grant] = {
178
+ # It reads its own call's log too, so a browser holds one token.
179
+ "talk": Grant(
180
+ connects=True,
181
+ audio=True,
182
+ writes=True,
183
+ reads_log=True,
184
+ own_call_only=True,
185
+ ttl_s=ONE_VISIT_TTL_S,
186
+ ),
187
+ # It subscribes: livekit delivers its text streams to subscribers only.
188
+ "chat": Grant(
189
+ connects=True,
190
+ audio=False,
191
+ hears=True,
192
+ writes=True,
193
+ reads_log=True,
194
+ own_call_only=True,
195
+ ttl_s=ONE_VISIT_TTL_S,
196
+ ),
197
+ "observe": Grant(
198
+ connects=False,
199
+ audio=False,
200
+ hears=True,
201
+ hidden=True,
202
+ reads_log=True,
203
+ own_call_only=False,
204
+ ttl_s=None,
205
+ ),
206
+ # Not hidden: livekit delivers no track of a hidden seat, so a takeover would be silent.
207
+ "supervise": Grant(
208
+ connects=False,
209
+ audio=True,
210
+ reads_log=True,
211
+ own_call_only=False,
212
+ ttl_s=None,
213
+ ),
214
+ "read": Grant(
215
+ connects=False,
216
+ audio=False,
217
+ reads_log=True,
218
+ own_call_only=True,
219
+ ttl_s=READ_TTL_S,
220
+ ),
221
+ "participate": Grant(
222
+ connects=False,
223
+ audio=False,
224
+ writes=True,
225
+ reads_log=True,
226
+ own_call_only=True,
227
+ ttl_s=None,
228
+ ),
229
+ }
230
+
231
+
232
+ # From the credential, never from a parameter: a scope that reads beyond its own call is the
233
+ # tenant's; an API key is the tenant.
234
+ PROJECTION_OF: Mapping[RoomScope, Projection] = {
235
+ scope: "tenant" if grant.reads_log and not grant.own_call_only else "public"
236
+ for scope, grant in GRANTS.items()
237
+ }
238
+
239
+
240
+ # What POST /v1/tokens may mint: the scopes that join a room.
241
+ MINTED_FOR_A_VISIT: frozenset[RoomScope] = frozenset(
242
+ scope for scope, grant in GRANTS.items() if grant.connects
243
+ )
244
+
245
+
246
+ def is_a_jwt(bearer: str) -> bool:
247
+ """Whether the bearer is shaped like a token, not a key: its shape, not its signature."""
248
+ parts = bearer.split(".")
249
+ return len(parts) == JWT_PARTS and all(parts)
250
+
251
+
252
+ def room_token(signer: Signer, call: str, scope: RoomScope, visitor: Visitor) -> str:
253
+ """A token that seats the visitor in the call's room with the scope's grants."""
254
+ grant = GRANTS[scope]
255
+ grants = VideoGrants(
256
+ room=call,
257
+ room_join=True,
258
+ can_publish=grant.audio,
259
+ can_subscribe=grant.audio or grant.hears,
260
+ # A widget writes to its call over the data channel; one that watches or listens does not.
261
+ can_publish_data=grant.writes,
262
+ can_publish_sources=[THE_MICROPHONE] if grant.audio else [],
263
+ hidden=grant.hidden,
264
+ )
265
+ who = {
266
+ attribute: value
267
+ for attribute, value in (
268
+ (SUBJECT_ATTRIBUTE, visitor.subject),
269
+ (NAME_ATTRIBUTE, visitor.name),
270
+ )
271
+ if value
272
+ }
273
+ token = (
274
+ AccessToken(signer.api_key, signer.secret)
275
+ .with_identity(visitor.identity or _identity(A_VISITOR))
276
+ .with_grants(grants)
277
+ # livekit takes a lifetime and stamps the expiry itself.
278
+ .with_ttl(timedelta(seconds=visitor.expires_at - time.time()))
279
+ .with_attributes({**who, SCOPE_ATTRIBUTE: scope})
280
+ )
281
+ if visitor.dispatch is not None:
282
+ token = token.with_room_config(visitor.dispatch)
283
+ return token.to_jwt()
284
+
285
+
286
+ def seat(signer: Signer, call: str, scope: RoomScope, bearer: Bearer) -> SupervisorSeat:
287
+ """A seat for the key's person in a live call, to listen or to supervise."""
288
+ identity = _identity(A_SEAT)
289
+ name = bearer.member.name if bearer.member is not None else bearer.key.name
290
+ visitor = Visitor(
291
+ expires_at=time.time() + A_SEAT_LASTS_S,
292
+ identity=identity,
293
+ subject=bearer.key.subject,
294
+ name=name,
295
+ )
296
+ token = room_token(signer, call, scope, visitor)
297
+ return SupervisorSeat(
298
+ call=call, identity=identity, token=token, subject=bearer.key.subject, name=name
299
+ )
300
+
301
+
302
+ # It names the call's room without the right to join it: livekit refuses it, and our doors read
303
+ # it as the call's.
304
+ def log_token(signer: Signer, call: str, projection: Projection) -> str:
305
+ """A token that reads one call's log and recording, through the projection named."""
306
+ attributes = {SCOPE_ATTRIBUTE: "read", PROJECTION_ATTRIBUTE: projection}
307
+ return _unjoinable(signer, call, READ_TTL_S, attributes)
308
+
309
+
310
+ def code_token(signer: Signer, code: str, agent: str, world: Env, expires_at: float) -> str:
311
+ """A token that asks after one caller code until the code dies; it opens no room."""
312
+ attributes = {
313
+ SCOPE_ATTRIBUTE: "read",
314
+ CODE_ATTRIBUTE: code,
315
+ AGENT_ATTRIBUTE: agent,
316
+ ENV_ATTRIBUTE: world,
317
+ }
318
+ return _unjoinable(signer, f"code:{code}", expires_at - time.time(), attributes)
319
+
320
+
321
+ def read(signer: Signer, token: str) -> Visit | None:
322
+ """What a token of ours says; None for anything else, whatever the reason."""
323
+ try:
324
+ ours = jwt.get_unverified_header(token).get("kid") == OUR_KEY_ID
325
+ decoded = jwt.decode(
326
+ token,
327
+ signer.own if ours else signer.secret,
328
+ algorithms=["HS256"],
329
+ options={"require": ["exp"]},
330
+ leeway=0,
331
+ )
332
+ claims = _Claims.model_validate(decoded)
333
+ except (jwt.PyJWTError, ValidationError):
334
+ return None
335
+ data = claims.attributes
336
+ # A livekit token minted for another purpose with the same pair reads nothing here, and a
337
+ # listener hears the room but reads no log. A reading token is the gateway's own key's alone:
338
+ # one in its shape under the pair is a worker's forgery.
339
+ if (
340
+ not claims.video.room
341
+ or data.scope not in BOUND_TO_ONE_CALL
342
+ or ours != (data.scope == "read")
343
+ ):
344
+ return None
345
+ return Visit(
346
+ call="" if data.code is not None else claims.video.room,
347
+ scope=data.scope,
348
+ expires_at=claims.exp,
349
+ identity=claims.sub,
350
+ subject=data.subject,
351
+ name=data.name,
352
+ projection=data.projection,
353
+ code=data.code,
354
+ agent=data.agent,
355
+ env=data.env,
356
+ )
357
+
358
+
359
+ async def minted(pool: Pool, token: MintedToken) -> None:
360
+ """Keep a room token the ledger will spend once, when its call opens."""
361
+ values = {
362
+ "call": token.call,
363
+ "org": token.org,
364
+ "env": token.env,
365
+ "agent": token.agent,
366
+ "scope": token.scope,
367
+ "expires_at": token.expires_at,
368
+ }
369
+ async with pool.connection() as connection:
370
+ await connection.execute(MINTED, values)
371
+
372
+
373
+ async def spend(pool: Pool, call: str, scope: Scope, agent: str) -> Spending:
374
+ """Spend the call's token in the scope the worker names, once; otherwise say why not."""
375
+ wanted = {"call": call, "org": scope.org, "env": scope.env, "agent": agent}
376
+ async with pool.connection() as connection:
377
+ row = await (await connection.execute(SPEND, wanted)).fetchone()
378
+ if row is not None and row["spent"]:
379
+ return "spent"
380
+ if row is None or row["minted"] is None:
381
+ return "never_minted"
382
+ return "minted_elsewhere" if row["spent_at"] is None else "already_spent"
383
+
384
+
385
+ # The shape LiveKit gives a token (sub, exp, video, attributes), so one reader reads both; signed
386
+ # with the gateway's own key under its key id, and joining no room.
387
+ def _unjoinable(signer: Signer, room: str, ttl_s: float, attributes: dict[str, str]) -> str:
388
+ now = int(time.time())
389
+ claims = {
390
+ "sub": _identity(A_VISITOR),
391
+ "iat": now,
392
+ "exp": now + int(ttl_s),
393
+ "video": {"room": room, "roomJoin": False},
394
+ "attributes": attributes,
395
+ }
396
+ return jwt.encode(claims, signer.own, algorithm="HS256", headers={"kid": OUR_KEY_ID})
397
+
398
+
399
+ def _identity(prefix: str) -> str:
400
+ return f"{prefix}{secrets.token_hex(IDENTITY_BYTES)}"
@@ -0,0 +1,70 @@
1
+ """A number's calls for a carrier's traceback: the calls kept, the calls erased, every dial."""
2
+
3
+ import time
4
+ from datetime import datetime
5
+
6
+ from psycopg.rows import DictRow
7
+
8
+ from pinecall.domain.names import parse_e164, parse_env
9
+ from pinecall.domain.scope import Scope
10
+ from pinecall.postgres.pool import Pool
11
+ from pinecall.tenancy import reads
12
+ from pinecall.tenancy.reads import Read
13
+ from pinecall.tenancy.retention import RECORDS_KEPT_S
14
+ from pinecall.wire.rest.ops import Traceback, TracebackCall, TracebackDial
15
+
16
+ # A kept call's facts, then an erased call's record: the same columns, oldest first. A call that
17
+ # never started (a dial nobody answered) is placed by when it ended.
18
+ CALLS = """
19
+ SELECT call, org, env, direction, from_number, to_number, started_at, ended_at, end_reason, erased
20
+ FROM (
21
+ SELECT head.call, head.org, head.env, facts.direction, facts.from_number, facts.to_number,
22
+ head.started_at, facts.ended_at, facts.end_reason, false AS erased
23
+ FROM call_facts facts JOIN call_log_head head ON head.call = facts.call
24
+ WHERE facts.channel = 'phone'
25
+ AND (facts.from_number = %(number)s OR facts.to_number = %(number)s)
26
+ UNION ALL
27
+ SELECT call, org, env, direction, from_number, to_number, started_at, ended_at, end_reason, true
28
+ FROM call_records WHERE from_number = %(number)s OR to_number = %(number)s
29
+ ) found
30
+ WHERE coalesce(started_at, ended_at, 0) >= %(since)s
31
+ ORDER BY coalesce(started_at, ended_at, 0)
32
+ """
33
+
34
+ # Every dial to the number, placed or refused, with who asked.
35
+ DIALS = """
36
+ SELECT org, env, agent, call, shown, asked_by, refused, at FROM dials
37
+ WHERE dialled = %(number)s AND at >= to_timestamp(%(since)s)
38
+ ORDER BY at
39
+ """
40
+
41
+
42
+ async def of_number(pool: Pool, number: str, since: float | None = None) -> Traceback:
43
+ """Every phone call with the number and every dial to it, since an epoch second or 24 months."""
44
+ kept = parse_e164(number)
45
+ start = time.time() - RECORDS_KEPT_S if since is None else since
46
+ params = {"number": kept, "since": start}
47
+ # independent: the calls and the dials of a number, read apart
48
+ async with pool.connection() as connection:
49
+ calls = await (await connection.execute(CALLS, params)).fetchall()
50
+ dials = await (await connection.execute(DIALS, params)).fetchall()
51
+ return Traceback(
52
+ number=kept,
53
+ since=start,
54
+ calls=[TracebackCall.model_validate(row) for row in calls],
55
+ dials=[_dial(row) for row in dials],
56
+ )
57
+
58
+
59
+ async def read_by(pool: Pool, found: Traceback, reader: str) -> None:
60
+ """Write the lookup into the access log of every org whose calls or dials it showed."""
61
+ seen = {(call.org, call.env) for call in found.calls if call.org is not None}
62
+ seen |= {(dial.org, dial.env) for dial in found.dials}
63
+ for org, env in sorted(seen, key=str):
64
+ scope = Scope(org, parse_env(env) if env else "production")
65
+ await reads.record(pool, scope, Read(found.number, "traceback", reader))
66
+
67
+
68
+ def _dial(row: DictRow) -> TracebackDial:
69
+ at: datetime = row["at"]
70
+ return TracebackDial.model_validate({**row, "at": at.timestamp()})
@@ -0,0 +1,136 @@
1
+ """What an org used in a world, month by month: the totals admission reads, and their refold."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import UTC, date, datetime
5
+
6
+ from psycopg.rows import DictRow
7
+
8
+ from pinecall.domain.names import Env, parse_env
9
+ from pinecall.log.reduce import Metered, Usage, usage_row
10
+ from pinecall.log.store import entry_of
11
+ from pinecall.postgres.pool import Pool, unbounded
12
+
13
+ # A quota is the month's: what the org used in the world in one calendar month, one row.
14
+ USED = """
15
+ SELECT calls, minutes, messages, input_tokens, output_tokens, characters, cost_usd
16
+ FROM usage_totals WHERE org = %(org)s AND env = %(env)s AND period = %(month)s
17
+ """
18
+
19
+ # A budget is the org's in both worlds: a month's spend is every world's row of that month.
20
+ SPENT_IN = """
21
+ SELECT coalesce(sum(cost_usd), 0) AS spent FROM usage_totals
22
+ WHERE org = %(org)s AND period = %(month)s
23
+ """
24
+
25
+ # The refold holds the table while it reads the log, so a summary written meanwhile waits and is
26
+ # counted after the table is rewritten, never twice and never lost.
27
+ HELD = "LOCK TABLE usage_totals IN EXCLUSIVE MODE"
28
+
29
+ SUMMARIES = """
30
+ SELECT head.org, head.env, entry.call, entry.seq, entry.ts, entry.agent, entry.type,
31
+ entry.ephemeral, entry.data
32
+ FROM call_log entry JOIN call_log_head head ON head.log = entry.log
33
+ WHERE entry.type = 'call.summary' AND head.org IS NOT NULL AND head.env IS NOT NULL
34
+ """
35
+
36
+ FORGOTTEN = "DELETE FROM usage_totals"
37
+
38
+ WRITTEN = """
39
+ INSERT INTO usage_totals
40
+ (org, env, period, calls, minutes, messages, input_tokens, output_tokens, characters, cost_usd)
41
+ VALUES (%(org)s, %(env)s, %(period)s, %(calls)s, %(minutes)s, %(messages)s, %(input_tokens)s,
42
+ %(output_tokens)s, %(characters)s, %(cost_usd)s)
43
+ """
44
+
45
+ ROWS = """
46
+ SELECT org, env, period, calls, minutes, messages, input_tokens, output_tokens, characters,
47
+ cost_usd
48
+ FROM usage_totals
49
+ """
50
+
51
+
52
+ @dataclass(frozen=True, order=True)
53
+ class Month:
54
+ """Whose a row of the totals is: the org, the world, the calendar month (UTC)."""
55
+
56
+ org: str
57
+ env: Env
58
+ period: date
59
+
60
+
61
+ async def used(pool: Pool, org: str, env: Env, month: date) -> Usage:
62
+ """What the org's calls in the world consumed in the month: one row read."""
63
+ params = {"org": org, "env": env, "month": month.replace(day=1)}
64
+ async with pool.connection() as connection:
65
+ row = await (await connection.execute(USED, params)).fetchone()
66
+ return Usage() if row is None else _usage(row)
67
+
68
+
69
+ async def spent_in(pool: Pool, org: str, month: date) -> float:
70
+ """What the org's calls in both worlds cost in the month their summaries were written."""
71
+ params = {"org": org, "month": month.replace(day=1)}
72
+ async with pool.connection() as connection:
73
+ row = await (await connection.execute(SPENT_IN, params)).fetchone()
74
+ return 0.0 if row is None else float(row["spent"])
75
+
76
+
77
+ async def rebuild(pool: Pool) -> dict[Month, Usage]:
78
+ """Refold the totals from every summary in the log, as the usage feed folds each one."""
79
+ async with unbounded(pool) as connection:
80
+ await connection.execute(HELD)
81
+ rows = await (await connection.execute(SUMMARIES)).fetchall()
82
+ refolded: dict[Month, Usage] = {}
83
+ for row in rows:
84
+ month = Month(str(row["org"]), parse_env(str(row["env"])), month_of(row["ts"]))
85
+ consumed = usage_row(Metered(position=0, org=month.org, entry=entry_of(row))).used
86
+ refolded[month] = refolded.get(month, Usage()) + consumed
87
+ await connection.execute(FORGOTTEN)
88
+ async with connection.cursor() as cursor:
89
+ await cursor.executemany(
90
+ WRITTEN,
91
+ [
92
+ {**vars(month), **_columns(consumed)}
93
+ for month, consumed in sorted(refolded.items())
94
+ ],
95
+ )
96
+ return refolded
97
+
98
+
99
+ async def totals(pool: Pool) -> dict[Month, Usage]:
100
+ """Every row of the totals, as the database keeps them."""
101
+ async with pool.connection() as connection:
102
+ rows = await (await connection.execute(ROWS)).fetchall()
103
+ return {
104
+ Month(str(row["org"]), parse_env(str(row["env"])), row["period"]): _usage(row)
105
+ for row in rows
106
+ }
107
+
108
+
109
+ def month_of(ts: float) -> date:
110
+ """The calendar month, in UTC, a summary written at that time is counted in."""
111
+ return datetime.fromtimestamp(ts, UTC).date().replace(day=1)
112
+
113
+
114
+ def _usage(row: DictRow) -> Usage:
115
+ return Usage(
116
+ calls=int(row["calls"]),
117
+ minutes=float(row["minutes"]),
118
+ messages=int(row["messages"]),
119
+ input_tokens=int(row["input_tokens"]),
120
+ output_tokens=int(row["output_tokens"]),
121
+ characters=int(row["characters"]),
122
+ cost_usd=float(row["cost_usd"]),
123
+ )
124
+
125
+
126
+ # A summary's usage has no judge calls: they are its score's, which the totals do not count.
127
+ def _columns(used: Usage) -> dict[str, object]:
128
+ return {
129
+ "calls": used.calls,
130
+ "minutes": used.minutes,
131
+ "messages": used.messages,
132
+ "input_tokens": used.input_tokens,
133
+ "output_tokens": used.output_tokens,
134
+ "characters": used.characters,
135
+ "cost_usd": used.cost_usd,
136
+ }