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,190 @@
1
+ """The pool an instance opens on its database, and the names a schema and a DSN go by."""
2
+
3
+ import asyncio
4
+ import contextvars
5
+ import re
6
+ import weakref
7
+ from collections.abc import AsyncGenerator, Coroutine, Generator
8
+ from contextlib import asynccontextmanager, contextmanager
9
+ from contextvars import ContextVar
10
+ from dataclasses import dataclass
11
+
12
+ import psycopg
13
+ from psycopg.conninfo import conninfo_to_dict
14
+ from psycopg.rows import DictRow, dict_row
15
+ from psycopg_pool import AsyncConnectionPool, PoolTimeout
16
+
17
+ from pinecall.domain.errors import DeclarationRefused, StoreUnreachable
18
+
19
+ type Connection = psycopg.AsyncConnection[DictRow]
20
+
21
+
22
+ type Pool = AsyncConnectionPool[Connection]
23
+
24
+
25
+ DEFAULT_SCHEMA = "public"
26
+
27
+
28
+ # A schema name also travels in the pool's libpq options string, where quoting does not reach.
29
+ _A_SCHEMA_NAME = re.compile(r"^[a-z_][a-z0-9_]*$")
30
+
31
+
32
+ CONNECT_TIMEOUT_S = 10
33
+
34
+
35
+ # What a gateway holds open to Postgres unless PINECALL_DB_POOL says otherwise: its writer's and
36
+ # its doors' together.
37
+ POOL_SIZE = 10
38
+
39
+
40
+ # SET LOCAL: they end with the transaction, and the connection goes back to the pool as it came.
41
+ UNBOUNDED = (
42
+ "SET LOCAL statement_timeout = 0",
43
+ "SET LOCAL idle_in_transaction_session_timeout = 0",
44
+ )
45
+
46
+
47
+ # Row-level security (migration 0096): a connection that names an org sees and writes that org's
48
+ # rows alone; one that names none is the box's, as the operator's doors and the loops are. The
49
+ # gateway names the org of a tenant's key for the whole request; a connection taken then says it.
50
+ SCOPED_ORG: ContextVar[str | None] = ContextVar("scoped_org", default=None)
51
+
52
+
53
+ SCOPE = "select set_config('pinecall.org', %s, false)"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Timeouts:
58
+ """How long a pool's statement may run, its transaction sit idle, and a request wait for it."""
59
+
60
+ # A door's statement past this is cancelled; what is long on purpose runs `unbounded`.
61
+ statement_ms: int = 30_000
62
+ # A transaction its process stopped talking in is ended, and its locks let go.
63
+ idle_in_transaction_ms: int = 60_000
64
+ # A request that finds the pool full this long is answered 503, which a worker outlasts.
65
+ wait_s: float = 2.0
66
+
67
+
68
+ TIMEOUTS = Timeouts()
69
+
70
+
71
+ # What each connection is known to say, so one that already says the org is asked nothing: a
72
+ # request's doors share a few connections, and the box's loops never name one. A connection is
73
+ # written here only once the server answered; absent means unknown, and unknown is asked.
74
+ _SAYS: weakref.WeakKeyDictionary[Connection, str] = weakref.WeakKeyDictionary()
75
+
76
+
77
+ async def open_pool(
78
+ dsn: str,
79
+ *,
80
+ schema: str = DEFAULT_SCHEMA,
81
+ max_size: int = POOL_SIZE,
82
+ min_size: int = 1,
83
+ timeouts: Timeouts = TIMEOUTS,
84
+ ) -> Pool:
85
+ """Open a pool on the schema, rows as dicts; raise StoreUnreachable when nothing answers."""
86
+ options = (
87
+ f"-c search_path={','.join(schemas_of(schema))}"
88
+ f" -c statement_timeout={timeouts.statement_ms}"
89
+ f" -c idle_in_transaction_session_timeout={timeouts.idle_in_transaction_ms}"
90
+ )
91
+ # A DSN's own options (a role to act as, say) are kept, and the pool's follow them.
92
+ given = conninfo_to_dict(dsn).get("options")
93
+ pool: Pool = AsyncConnectionPool(
94
+ dsn,
95
+ connection_class=psycopg.AsyncConnection[DictRow],
96
+ # A statement alone is one round trip; what belongs together opens connection.transaction().
97
+ kwargs={
98
+ "autocommit": True,
99
+ "row_factory": dict_row,
100
+ "options": options if not given else f"{given} {options}",
101
+ },
102
+ min_size=min_size,
103
+ max_size=max_size,
104
+ timeout=timeouts.wait_s,
105
+ open=False,
106
+ check=_scoped,
107
+ )
108
+ try:
109
+ await pool.open(wait=True, timeout=CONNECT_TIMEOUT_S)
110
+ except (PoolTimeout, psycopg.OperationalError) as refused:
111
+ await pool.close()
112
+ raise StoreUnreachable(f"{database_named(dsn)}: {refused}") from refused
113
+ return pool
114
+
115
+
116
+ # Erasure, an export, the nightly retention and a knowledge base's index are long on purpose.
117
+ @asynccontextmanager
118
+ async def unbounded(pool: Pool) -> AsyncGenerator[Connection]:
119
+ """A connection in a transaction that no statement or idle timeout of the pool ends."""
120
+ async with pool.connection() as connection, connection.transaction():
121
+ for statement in UNBOUNDED:
122
+ await connection.execute(statement)
123
+ yield connection
124
+
125
+
126
+ def scope_to(org: str) -> None:
127
+ """Name the org every connection taken from here on in this request acts for."""
128
+ SCOPED_ORG.set(org)
129
+
130
+
131
+ # What a tenant's request reads across orgs on purpose (is this address in another org, does
132
+ # another org hold this number) is read box-wide, in a block that says so.
133
+ @contextmanager
134
+ def box_wide() -> Generator[None]:
135
+ """Take connections as the box's within the block, whatever the request's org."""
136
+ token = SCOPED_ORG.set(None)
137
+ try:
138
+ yield
139
+ finally:
140
+ SCOPED_ORG.reset(token)
141
+
142
+
143
+ # A task of the process (the log's writer, the relay, a cache's listener) may start inside a
144
+ # tenant's request: it starts box-wide, or it would act for that org for the rest of its life.
145
+ def box_task[T](work: Coroutine[object, object, T]) -> asyncio.Task[T]:
146
+ """Start the work as a task of the box's, whatever org the request starting it acts for."""
147
+ context = contextvars.copy_context()
148
+ context.run(SCOPED_ORG.set, None)
149
+ return asyncio.create_task(work, context=context)
150
+
151
+
152
+ async def connect(dsn: str) -> Connection:
153
+ """Open one autocommit connection, rows as dicts; the caller closes it."""
154
+ return await psycopg.AsyncConnection[DictRow].connect(
155
+ dsn, autocommit=True, row_factory=dict_row, connect_timeout=CONNECT_TIMEOUT_S
156
+ )
157
+
158
+
159
+ # Extension types and operators (halfvec, <=>, bm25's <@>) live in public, so another schema
160
+ # keeps public on its search path.
161
+ def schemas_of(schema: str) -> tuple[str, ...]:
162
+ """Return the search path for a schema: the schema, then public."""
163
+ name = check_schema_name(schema)
164
+ return (name,) if name == DEFAULT_SCHEMA else (name, DEFAULT_SCHEMA)
165
+
166
+
167
+ def check_schema_name(schema: str) -> str:
168
+ """Return the schema name, raising DeclarationRefused unless it is a plain lowercase word."""
169
+ if not _A_SCHEMA_NAME.match(schema):
170
+ raise DeclarationRefused(f"a schema name is a lowercase word, not {schema!r}")
171
+ return schema
172
+
173
+
174
+ # Every message that names the database goes through this: a DSN carries the password.
175
+ def database_named(dsn: str) -> str:
176
+ """Return `host/database` from the DSN, with no credentials."""
177
+ parts = conninfo_to_dict(dsn)
178
+ return f"{parts.get('host', 'localhost')}/{parts.get('dbname', '')}"
179
+
180
+
181
+ # The memory is dropped before the set and written after it: a set cut between the server's
182
+ # answer and ours (a request cancelled) leaves a connection the server has moved and we have
183
+ # forgotten, which the next checkout asks again, never one we remember wrong.
184
+ async def _scoped(connection: Connection) -> None:
185
+ wanted = SCOPED_ORG.get() or ""
186
+ if _SAYS.get(connection) == wanted:
187
+ return
188
+ _SAYS.pop(connection, None)
189
+ await connection.execute(SCOPE, (wanted,))
190
+ _SAYS[connection] = wanted
File without changes
@@ -0,0 +1,97 @@
1
+ """The box's object store, spoken in S3: any endpoint that takes AWS's version 4 signature."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from urllib.parse import quote, urlsplit
9
+
10
+ import httpx
11
+
12
+ from pinecall.process.settings import Settings
13
+
14
+ ALGORITHM = "AWS4-HMAC-SHA256"
15
+
16
+ # What S3 is told a request without a body hashes to.
17
+ NO_BODY = hashlib.sha256(b"").hexdigest()
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Signing:
22
+ """What a request is signed with: the region the store names, and the key's id and secret."""
23
+
24
+ region: str
25
+ access_key_id: str
26
+ secret_access_key: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ObjectStore:
31
+ """An S3-compatible endpoint and the key the box reads and writes it with."""
32
+
33
+ endpoint: str
34
+ signing: Signing
35
+ http: httpx.AsyncClient
36
+
37
+ # Path-style: the one address every S3-compatible store answers; a bucket as a host name is
38
+ # AWS's and a few others' alone.
39
+ def url_of(self, bucket: str, name: str) -> str:
40
+ """The object's address."""
41
+ return f"{self.endpoint.rstrip('/')}/{bucket}/{quote(name, safe='/')}"
42
+
43
+ def signed(
44
+ self, method: str, url: str, headers: Mapping[str, str], payload_sha256: str = NO_BODY
45
+ ) -> dict[str, str]:
46
+ """The headers to send, signed now: the ones given, the date, the hash and the proof."""
47
+ return signature(method, url, headers, self.signing, (payload_sha256, datetime.now(UTC)))
48
+
49
+
50
+ def object_store_of(settings: Settings, http: httpx.AsyncClient) -> ObjectStore | None:
51
+ """The store the settings name whole; None when any of its four is missing."""
52
+ endpoint, region = settings.s3_endpoint, settings.s3_region
53
+ key, secret = settings.s3_access_key_id, settings.s3_secret_access_key
54
+ if endpoint is None or region is None or key is None or secret is None:
55
+ return None
56
+ return ObjectStore(endpoint, Signing(region, key, secret), http)
57
+
58
+
59
+ # https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html. The store's
60
+ # requests name no query, so the canonical query is the empty line.
61
+ def signature(
62
+ method: str,
63
+ url: str,
64
+ headers: Mapping[str, str],
65
+ signing: Signing,
66
+ payload: tuple[str, datetime],
67
+ ) -> dict[str, str]:
68
+ """The headers with the date, the payload's hash and the Authorization that signs them all."""
69
+ payload_sha256, now = payload
70
+ stamp = now.strftime("%Y%m%dT%H%M%SZ")
71
+ parts = urlsplit(url)
72
+ signed = {
73
+ **{name.lower(): value.strip() for name, value in headers.items()},
74
+ "host": parts.netloc,
75
+ "x-amz-content-sha256": payload_sha256,
76
+ "x-amz-date": stamp,
77
+ }
78
+ names = sorted(signed)
79
+ canonical = "\n".join(
80
+ (
81
+ method,
82
+ parts.path or "/",
83
+ "",
84
+ "".join(f"{name}:{signed[name]}\n" for name in names),
85
+ ";".join(names),
86
+ payload_sha256,
87
+ )
88
+ )
89
+ scope = f"{stamp[:8]}/{signing.region}/s3/aws4_request"
90
+ to_sign = "\n".join((ALGORITHM, stamp, scope, hashlib.sha256(canonical.encode()).hexdigest()))
91
+ key = f"AWS4{signing.secret_access_key}".encode()
92
+ for step in (stamp[:8], signing.region, "s3", "aws4_request"):
93
+ key = hmac.new(key, step.encode(), hashlib.sha256).digest()
94
+ proof = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
95
+ credential = f"Credential={signing.access_key_id}/{scope}"
96
+ authorization = f"{ALGORITHM} {credential}, SignedHeaders={';'.join(names)}, Signature={proof}"
97
+ return {**signed, "authorization": authorization}
@@ -0,0 +1,23 @@
1
+ """The box_settings rows: what the operator configured for this box, one JSON value per name."""
2
+
3
+ from psycopg.types.json import Jsonb
4
+
5
+ from pinecall.domain.names import JsonObject
6
+ from pinecall.postgres.pool import Connection
7
+
8
+ READ = "SELECT value FROM box_settings WHERE name = %(name)s"
9
+ WRITE = """
10
+ INSERT INTO box_settings (name, value) VALUES (%(name)s, %(value)s)
11
+ ON CONFLICT (name) DO UPDATE SET value = excluded.value, set_at = now()
12
+ """
13
+
14
+
15
+ async def read(connection: Connection, name: str) -> JsonObject | None:
16
+ """The row's value, or None when the operator never set it."""
17
+ row = await (await connection.execute(READ, {"name": name})).fetchone()
18
+ return None if row is None else row["value"]
19
+
20
+
21
+ async def write(connection: Connection, name: str, value: JsonObject) -> None:
22
+ """Set the row's value, whole."""
23
+ await connection.execute(WRITE, {"name": name, "value": Jsonb(value)})
@@ -0,0 +1,124 @@
1
+ """What one process opens at start and closes at the end: the database, the vault, HTTP, LiveKit."""
2
+
3
+ from collections.abc import AsyncGenerator, Mapping
4
+ from contextlib import asynccontextmanager
5
+ from dataclasses import dataclass, field
6
+
7
+ import httpx
8
+ from cryptography.fernet import Fernet, MultiFernet
9
+ from livekit import api
10
+
11
+ from pinecall.domain.errors import NotAvailable, SettingsRefused
12
+ from pinecall.domain.names import PRODUCTION, SANDBOX, Env
13
+ from pinecall.postgres.pool import Pool, open_pool
14
+ from pinecall.process.settings import Settings
15
+ from pinecall.process.signal import LocalSignal, Signal, opened_signal
16
+
17
+ VARIABLE = "PINECALL_VAULT_KEY"
18
+
19
+
20
+ # The log's writer holds a connection per lane (log/_writer.py LANES), open for the process's
21
+ # life; the doors share the rest of PINECALL_DB_POOL.
22
+ WRITING = 2
23
+
24
+
25
+ NO_LIVEKIT = "LIVEKIT_API_KEY and LIVEKIT_API_SECRET are unset: this process cannot reach the SFU"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Connections:
30
+ """The settings a process was given, and the five things it holds open on them."""
31
+
32
+ settings: Settings
33
+ # The doors'.
34
+ pool: Pool
35
+ # The log writer's own: never waits for a door, and a door never waits for it.
36
+ writing: Pool
37
+ vault: MultiFernet
38
+ # The carriers', Meta's and the identity providers' HTTP, one pool per process.
39
+ http: httpx.AsyncClient
40
+ # Each world's LiveKit server API, one client per LiveKit: both worlds hold the one client
41
+ # when the sandbox has no LiveKit of its own.
42
+ servers: Mapping[Env, api.LiveKitAPI]
43
+ # What the gateways tell each other; a process given no Redis keeps it to itself.
44
+ signal: Signal = field(default_factory=LocalSignal)
45
+
46
+
47
+ UNSET = (
48
+ f"{VARIABLE} is unset: every vendor key, mailbox and sign-in secret is sealed under it, "
49
+ "so the gateway does not start without it. `Fernet.generate_key()` makes one"
50
+ )
51
+
52
+
53
+ NOT_A_KEY = (
54
+ f"{VARIABLE} holds something that is not a Fernet key: a comma-separated list of "
55
+ "32-byte url-safe base64 keys, the newest first"
56
+ )
57
+
58
+
59
+ @asynccontextmanager
60
+ async def opened(settings: Settings) -> AsyncGenerator[Connections]:
61
+ """Open everything, the vault first: a box without its key starts nothing; closed in reverse."""
62
+ sealed = vault_of(settings.vault_key)
63
+ servers = servers_of(settings)
64
+ pool = await open_pool(settings.database_url, max_size=settings.db_pool - WRITING)
65
+ try:
66
+ writing = await open_pool(settings.database_url, max_size=WRITING, min_size=WRITING)
67
+ except BaseException:
68
+ await pool.close()
69
+ raise
70
+ try:
71
+ async with httpx.AsyncClient() as http, opened_signal(settings) as signal:
72
+ yield Connections(
73
+ settings=settings,
74
+ pool=pool,
75
+ writing=writing,
76
+ vault=sealed,
77
+ http=http,
78
+ servers=servers,
79
+ signal=signal,
80
+ )
81
+ finally:
82
+ await closed(servers)
83
+ await writing.close()
84
+ await pool.close()
85
+
86
+
87
+ # livekit-api opens an HTTP session per client: one per LiveKit and process, closed with it. Both
88
+ # LiveKits take the box's one key pair, so a token the gateway signs opens a room on either.
89
+ def servers_of(settings: Settings) -> dict[Env, api.LiveKitAPI]:
90
+ """Each world's LiveKit server API, on the box's key pair: one client where they share one."""
91
+ if not settings.livekit_api_key or not settings.livekit_api_secret:
92
+ raise NotAvailable(NO_LIVEKIT)
93
+ key, secret = settings.livekit_api_key, settings.livekit_api_secret
94
+ production = api.LiveKitAPI(settings.livekit_url_of(PRODUCTION), key, secret)
95
+ if settings.livekit_url_of(SANDBOX) == settings.livekit_url_of(PRODUCTION):
96
+ return {PRODUCTION: production, SANDBOX: production}
97
+ return {
98
+ PRODUCTION: production,
99
+ SANDBOX: api.LiveKitAPI(settings.livekit_url_of(SANDBOX), key, secret),
100
+ }
101
+
102
+
103
+ async def closed(servers: Mapping[Env, api.LiveKitAPI]) -> None:
104
+ """Close each LiveKit's client once, however many worlds hold it."""
105
+ for server in dict.fromkeys(servers.values()):
106
+ await server.aclose()
107
+
108
+
109
+ def vault_of(keys: str | None) -> MultiFernet:
110
+ """The vault built from the setting: the first key seals, any key listed opens."""
111
+ return MultiFernet(keyring_of(keys))
112
+
113
+
114
+ def keyring_of(keys: str | None) -> list[Fernet]:
115
+ """Every key the setting lists, in its order: the first is the one that seals."""
116
+ if keys is None:
117
+ raise SettingsRefused(UNSET)
118
+ listed = [item.strip() for item in keys.split(",") if item.strip()]
119
+ if not listed:
120
+ raise SettingsRefused(NOT_A_KEY)
121
+ try:
122
+ return [Fernet(listed_one) for listed_one in listed]
123
+ except ValueError:
124
+ raise SettingsRefused(NOT_A_KEY) from None
@@ -0,0 +1,127 @@
1
+ """What a process counts as it works, in memory, and the Prometheus text it is read as."""
2
+
3
+ from bisect import bisect_left
4
+ from collections import Counter, deque
5
+ from collections.abc import Iterable, Mapping
6
+ from typing import Literal
7
+
8
+ # Seconds: an append waits on one Postgres transaction, so the buckets are a transaction's.
9
+ APPEND_BOUNDS_S = (0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0)
10
+
11
+ # A stage's fallbacks are ordered by the last two minutes of each vendor: judged once five calls
12
+ # were handed it as a stage's first, and over the line once half of those calls saw it fail.
13
+ VENDOR_WINDOW_S = 120.0
14
+ CALLS_TO_JUDGE = 5
15
+ FAILED_SHARE = 0.5
16
+ # A busy window is thousands of calls: past this many, a vendor's oldest are forgotten early.
17
+ MOST_KEPT = 10_000
18
+
19
+
20
+ type Rows = Iterable[tuple[Mapping[str, str], float]]
21
+
22
+ type Kind = Literal["gauge", "counter"]
23
+
24
+
25
+ class Histogram:
26
+ """Observations counted into fixed buckets, with their sum, as Prometheus reads one."""
27
+
28
+ def __init__(self, bounds: tuple[float, ...]) -> None:
29
+ """Every bucket empty."""
30
+ self.bounds = bounds
31
+ self.counts = [0] * (len(bounds) + 1)
32
+ self.total = 0.0
33
+
34
+ def observe(self, value: float) -> None:
35
+ """Count one observation in the first bucket whose bound holds it."""
36
+ self.counts[bisect_left(self.bounds, value)] += 1
37
+ self.total += value
38
+
39
+
40
+ # Called on the append path: a bisect and three additions, no lock (one event loop).
41
+ class Counters:
42
+ """The gateway's counts since it started: appends, their time, and the errors workers wrote."""
43
+
44
+ def __init__(self) -> None:
45
+ """Nothing counted yet."""
46
+ self.append_seconds = Histogram(APPEND_BOUNDS_S)
47
+ self.appended = 0
48
+ # By the entry's code and the vendor whose plugin failed, "" when none did.
49
+ self.errors: Counter[tuple[str, str]] = Counter()
50
+ # By vendor: when a call was handed it first for a stage, and when a call saw it fail.
51
+ self.handed: dict[str, deque[float]] = {}
52
+ self.failures: dict[str, deque[tuple[float, str]]] = {}
53
+ # By org: how many times its usual day today's spend is, while it is unusual.
54
+ self.unusual: dict[str, float] = {}
55
+
56
+ def appended_in(self, seconds: float, entries: int) -> None:
57
+ """One append at the door: how long it took and how many entries it wrote."""
58
+ self.append_seconds.observe(seconds)
59
+ self.appended += entries
60
+
61
+ def failed(self, code: str, vendor: str) -> None:
62
+ """One error entry a worker wrote."""
63
+ self.errors[(code, vendor)] += 1
64
+
65
+ def failed_on(self, vendor: str, call: str, at: float) -> None:
66
+ """A call saw the vendor fail: an error naming it, or its stage's switch away from it."""
67
+ self.failures.setdefault(vendor, deque(maxlen=MOST_KEPT)).append((at, call))
68
+
69
+ def handed_out(self, vendors: Iterable[str], at: float) -> None:
70
+ """A call's stages were resolved with these vendors first, each counted once."""
71
+ for vendor in set(vendors):
72
+ self.handed.setdefault(vendor, deque(maxlen=MOST_KEPT)).append(at)
73
+
74
+ def spending(self, org: str, multiple: float | None) -> None:
75
+ """What the last seal found of the org's spend: unusual by this much, or None, usual."""
76
+ if multiple is None:
77
+ self.unusual.pop(org, None)
78
+ else:
79
+ self.unusual[org] = multiple
80
+
81
+ def failing(self, at: float) -> frozenset[str]:
82
+ """The vendors over their error line in the window up to now."""
83
+ since = at - VENDOR_WINDOW_S
84
+ for failures in self.failures.values():
85
+ while failures and failures[0][0] < since:
86
+ failures.popleft()
87
+ over: set[str] = set()
88
+ for vendor, handed in self.handed.items():
89
+ while handed and handed[0] < since:
90
+ handed.popleft()
91
+ calls = len({call for _, call in self.failures.get(vendor, ())})
92
+ if len(handed) >= CALLS_TO_JUDGE and calls >= FAILED_SHARE * len(handed):
93
+ over.add(vendor)
94
+ return frozenset(over)
95
+
96
+
97
+ def family(name: str, what: str, kind: Kind, rows: Rows) -> str:
98
+ """One gauge or counter family, a line per label set, with its help and type lines."""
99
+ lines = [f"# HELP {name} {what}", f"# TYPE {name} {kind}"]
100
+ lines += [f"{name}{_labelled(labels)} {_number(value)}" for labels, value in rows]
101
+ return "\n".join(lines) + "\n"
102
+
103
+
104
+ def histogram(name: str, what: str, observed: Histogram) -> str:
105
+ """One histogram: its cumulative buckets, the sum and the count."""
106
+ lines = [f"# HELP {name} {what}", f"# TYPE {name} histogram"]
107
+ running = 0
108
+ for bound, count in zip((*observed.bounds, float("inf")), observed.counts, strict=True):
109
+ running += count
110
+ lines.append(f'{name}_bucket{{le="{_number(bound)}"}} {running}')
111
+ lines += [f"{name}_sum {_number(observed.total)}", f"{name}_count {running}"]
112
+ return "\n".join(lines) + "\n"
113
+
114
+
115
+ # The text format's three escapes in a label value: backslash, quote, newline.
116
+ def _labelled(labels: Mapping[str, str]) -> str:
117
+ if not labels:
118
+ return ""
119
+ pairs = (
120
+ f'{key}="{value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")}"'
121
+ for key, value in labels.items()
122
+ )
123
+ return "{" + ",".join(pairs) + "}"
124
+
125
+
126
+ def _number(value: float) -> str:
127
+ return "+Inf" if value == float("inf") else repr(float(value))