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,504 @@
1
+ """The gateway process: wired once at start, its doors, the one answer to a refusal, its pages."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ from collections.abc import AsyncGenerator
7
+ from contextlib import AsyncExitStack, asynccontextmanager
8
+ from pathlib import Path
9
+
10
+ from fastapi import FastAPI, HTTPException, Request, Response
11
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
12
+ from psycopg.errors import QueryCanceled
13
+ from psycopg_pool import PoolTimeout
14
+ from starlette.datastructures import Headers
15
+ from starlette.middleware.cors import CORSMiddleware
16
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
17
+
18
+ from pinecall.channels.telephony.sip import rebuilt_until_whole
19
+ from pinecall.domain.errors import (
20
+ DeclarationRefused,
21
+ NotAvailable,
22
+ NotSignedIn,
23
+ PinecallError,
24
+ SettingsRefused,
25
+ StoreUnreachable,
26
+ Throttled,
27
+ )
28
+ from pinecall.evals.runs import Runner
29
+ from pinecall.fleet.roster import Roster
30
+ from pinecall.gateway import _deps
31
+ from pinecall.gateway._gateway import Gateway
32
+ from pinecall.gateway._served import ServedCalls, Serving
33
+ from pinecall.gateway._sockets import Sockets
34
+ from pinecall.gateway.api import (
35
+ accounts,
36
+ agents,
37
+ apps,
38
+ box,
39
+ calibration,
40
+ callbacks,
41
+ calls,
42
+ chat,
43
+ dataset,
44
+ desk,
45
+ entries,
46
+ evals,
47
+ fleet,
48
+ hosting,
49
+ judges,
50
+ keys,
51
+ line,
52
+ members,
53
+ metrics,
54
+ numbers,
55
+ ops,
56
+ org,
57
+ personas,
58
+ pipeline,
59
+ prompts,
60
+ providers,
61
+ recordings,
62
+ relay,
63
+ retrieval,
64
+ runner,
65
+ sessions,
66
+ settings,
67
+ signup,
68
+ sso_login,
69
+ threads,
70
+ usage,
71
+ visitors,
72
+ whatsapp,
73
+ widget,
74
+ )
75
+ from pinecall.gateway.api.providers import SAMPLES_A_MINUTE
76
+ from pinecall.gateway.calls.threads import Threads
77
+ from pinecall.gateway.dispatching.sweep import sweep_forever
78
+ from pinecall.gateway.ending.reaper import reap_forever
79
+ from pinecall.log.logs import Logs
80
+ from pinecall.log.store import Store
81
+ from pinecall.postgres.pool import TIMEOUTS, box_task
82
+ from pinecall.process.connections import Connections, opened
83
+ from pinecall.process.settings import Settings, load
84
+ from pinecall.providers import catalog
85
+ from pinecall.retrieval.embed import Embedder
86
+ from pinecall.tenancy.codes import Codes
87
+ from pinecall.tenancy.knocks import Throttle
88
+ from pinecall.tenancy.mail import Mailbox, Outbox, parse_mailbox_url
89
+ from pinecall.tenancy.remembered import RememberedKeys
90
+ from pinecall.tenancy.signin import SignIns
91
+ from pinecall.tenancy.throttle import Window
92
+ from pinecall.tenancy.tokens import Signer
93
+ from pinecall.tenancy.vault import box_credentials
94
+ from pinecall.tenancy.words import Words
95
+
96
+ logger = logging.getLogger(__name__)
97
+
98
+
99
+ NO_EMBEDDER = "no providers row: the gateway starts with no embedder"
100
+
101
+
102
+ NO_EMBEDDER_KEY = "the providers row embeds with %s and the box holds no key for it: no embedder"
103
+
104
+
105
+ NO_LIVEKIT = "LIVEKIT_API_KEY and LIVEKIT_API_SECRET: the gateway signs every room token with them"
106
+
107
+
108
+ NO_SIGNING = "PINECALL_TOKEN_KEY: the gateway signs its log and code tokens with it"
109
+
110
+
111
+ NO_BOX_MAIL = "PINECALL_SMTP_URL does not read (%s): the box posts no letter of its own"
112
+
113
+
114
+ # The two ways a database too busy to answer now shows; a worker outlasts a 503 and retries.
115
+ NO_CONNECTION = "the database is busy: no connection came free within {wait:g} s; try again"
116
+
117
+
118
+ TOO_SLOW = "the database took more than {took:g} s to answer; try again"
119
+
120
+
121
+ # Capacitor's WebView origins: iOS serves from capacitor://localhost, Android from https://localhost.
122
+ THE_APPS_WEBVIEWS = ("capacitor://localhost", "https://localhost")
123
+
124
+
125
+ DOORS = "/v1/"
126
+
127
+
128
+ METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
129
+
130
+
131
+ # A bearer, never a cookie: Allow-Credentials is never sent.
132
+ ALLOWED_HEADERS = ("authorization", "content-type", _deps.WORLD, _deps.LOOKING_AT, "last-event-id")
133
+
134
+
135
+ PREFLIGHT_KEPT_S = 600
136
+
137
+
138
+ # The most any request carries, above every door's own cap (a hold melody 20 MiB, a hosted app's
139
+ # source 10 MiB): a body past it is refused as it streams in, never held whole in memory, and the
140
+ # webhooks that read a body to check its signature read at most this much of an unsigned one.
141
+ LARGEST_BODY = 32 * 1024 * 1024
142
+
143
+
144
+ TOO_LARGE = "the body is over {most} MiB, more than any door takes"
145
+
146
+
147
+ # A token of one call reads its own doors from any page: GET only, no credentials.
148
+ A_PAGES_READS = re.compile(r"^/v1/(calls/[^/]+/(events|state|recording)|codes/[^/]+)$")
149
+
150
+
151
+ READ_HEADERS = ("authorization", "last-event-id", "accept", "range")
152
+
153
+
154
+ READ_EXPOSED = ("content-range", "accept-ranges", "content-length")
155
+
156
+
157
+ # The console and the widget the wheel carries (scripts/hatch_build.py puts them here).
158
+ BUILT = Path(__file__).resolve().parents[1] / "public"
159
+
160
+
161
+ THE_PAGE = "index.html"
162
+
163
+
164
+ API_PREFIXES = ("v1/", ".well-known/")
165
+
166
+
167
+ NOT_BUILT = "the console is not built into this gateway: `make deploy` builds it in"
168
+
169
+
170
+ WIDGET_HEADERS = {"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=300"}
171
+
172
+
173
+ # The console is framed by nobody (a page that framed it could dress a click as another), reached
174
+ # over HTTPS alone once a browser has seen it, read as the type it is served as, and tells another
175
+ # site no more of where it came from than its origin (a sign-in lands with a code in the query).
176
+ PAGE_HEADERS = {
177
+ "Content-Security-Policy": "frame-ancestors 'none'",
178
+ "X-Frame-Options": "DENY",
179
+ "Strict-Transport-Security": "max-age=31536000",
180
+ "X-Content-Type-Options": "nosniff",
181
+ "Referrer-Policy": "strict-origin-when-cross-origin",
182
+ }
183
+
184
+
185
+ ROUTERS = (
186
+ accounts,
187
+ agents,
188
+ apps,
189
+ box,
190
+ calibration,
191
+ callbacks,
192
+ calls,
193
+ chat,
194
+ dataset,
195
+ desk,
196
+ entries,
197
+ evals,
198
+ fleet,
199
+ hosting,
200
+ judges,
201
+ keys,
202
+ line,
203
+ members,
204
+ metrics,
205
+ numbers,
206
+ ops,
207
+ org,
208
+ personas,
209
+ pipeline,
210
+ prompts,
211
+ providers,
212
+ recordings,
213
+ relay,
214
+ retrieval,
215
+ runner,
216
+ sessions,
217
+ settings,
218
+ signup,
219
+ sso_login,
220
+ threads,
221
+ usage,
222
+ visitors,
223
+ whatsapp,
224
+ widget,
225
+ )
226
+
227
+
228
+ # Read per request: the settings are loaded by the lifespan, after the middleware is built.
229
+ class AppOrigins:
230
+ """CORS on /v1 for the allowed origins; any page may read what a call's token reads."""
231
+
232
+ def __init__(self, app: ASGIApp) -> None:
233
+ """Nothing allowed until the settings say what."""
234
+ self.app = app
235
+ self.cors: tuple[tuple[str, ...], CORSMiddleware] | None = None
236
+ self.any_page = CORSMiddleware(
237
+ app,
238
+ allow_origins=("*",),
239
+ allow_methods=("GET",),
240
+ allow_headers=READ_HEADERS,
241
+ expose_headers=READ_EXPOSED,
242
+ max_age=PREFLIGHT_KEPT_S,
243
+ )
244
+
245
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
246
+ """Route an allowed origin's /v1 request through CORS, and anything else straight in."""
247
+ if scope["type"] != "http" or not str(scope["path"]).startswith(DOORS):
248
+ await self.app(scope, receive, send)
249
+ return
250
+ origin = Headers(scope=scope).get("origin")
251
+ gateway: object = getattr(scope["app"].state, "gateway", None)
252
+ allowed = (
253
+ origins_allowed(gateway.connections.settings)
254
+ if isinstance(gateway, Gateway) and origin
255
+ else ()
256
+ )
257
+ if origin and origin not in allowed and A_PAGES_READS.match(str(scope["path"])):
258
+ await self.any_page(scope, receive, send)
259
+ return
260
+ if not origin or origin not in allowed:
261
+ await self.app(scope, receive, send)
262
+ return
263
+ await self._cors_for(allowed)(scope, receive, send)
264
+
265
+ def _cors_for(self, allowed: tuple[str, ...]) -> CORSMiddleware:
266
+ if self.cors is None or self.cors[0] != allowed:
267
+ cors = CORSMiddleware(
268
+ self.app,
269
+ allow_origins=allowed,
270
+ allow_methods=METHODS,
271
+ allow_headers=ALLOWED_HEADERS,
272
+ max_age=PREFLIGHT_KEPT_S,
273
+ )
274
+ self.cors = (allowed, cors)
275
+ return self.cors[1]
276
+
277
+
278
+ # Said by Content-Length, refused at once; said by nothing (chunked), counted as it comes and
279
+ # answered 413 the chunk it passes the most. The door is then told the client left, so it stops
280
+ # reading, and whatever it answers after is dropped: the 413 is the one answer.
281
+ class BodyCeiling:
282
+ """No request's body is over LARGEST_BODY."""
283
+
284
+ def __init__(self, app: ASGIApp) -> None:
285
+ """The app it stands in front of."""
286
+ self.app = app
287
+
288
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
289
+ """Pass the request on, its body counted."""
290
+ if scope["type"] != "http":
291
+ await self.app(scope, receive, send)
292
+ return
293
+ too_large = JSONResponse(
294
+ {"detail": TOO_LARGE.format(most=LARGEST_BODY // (1024 * 1024))}, status_code=413
295
+ )
296
+ declared = Headers(scope=scope).get("content-length")
297
+ if declared is not None and declared.isdigit() and int(declared) > LARGEST_BODY:
298
+ await too_large(scope, receive, send)
299
+ return
300
+ counted = 0
301
+ answered = False
302
+
303
+ async def counting() -> Message:
304
+ nonlocal counted, answered
305
+ message = await receive()
306
+ counted += len(message.get("body", b""))
307
+ if counted > LARGEST_BODY and not answered:
308
+ answered = True
309
+ await too_large(scope, receive, send)
310
+ return {"type": "http.disconnect"} if answered else message
311
+
312
+ async def unless_answered(message: Message) -> None:
313
+ if not answered:
314
+ await send(message)
315
+
316
+ await self.app(scope, counting, unless_answered)
317
+
318
+
319
+ # Everything opened goes on the stack: a failed start closes what opened, a stop closes it all.
320
+ @asynccontextmanager
321
+ async def lifespan(fastapi_app: FastAPI) -> AsyncGenerator[None]:
322
+ """Wire the box once, run the reaper, and close all of it after."""
323
+ settings = load()
324
+ async with AsyncExitStack() as stack:
325
+ gateway = await wire(settings, stack)
326
+ fastapi_app.state.gateway = gateway
327
+ reaper = box_task(reap_forever(gateway.serving, gateway.connections.servers))
328
+ stack.push_async_callback(_cancelled, reaper)
329
+ sweep = box_task(sweep_forever(gateway.offering))
330
+ stack.push_async_callback(_cancelled, sweep)
331
+ gateway.loops.update(reaper=reaper, sweep=sweep)
332
+ # After the start, so a slow SFU never keeps the gateway from answering; again until whole.
333
+ rebuilt = box_task(rebuilt_until_whole(gateway.connections))
334
+ stack.push_async_callback(_cancelled, rebuilt)
335
+ yield
336
+
337
+
338
+ async def wire(settings: Settings, stack: AsyncExitStack) -> Gateway:
339
+ """Open the connections, then what the gateway keeps in memory; the reverse at the end."""
340
+ connections = await stack.enter_async_context(opened(settings))
341
+ if not settings.livekit_api_key or not settings.livekit_api_secret:
342
+ raise SettingsRefused(NO_LIVEKIT)
343
+ if not settings.token_key:
344
+ raise SettingsRefused(NO_SIGNING)
345
+ logs = Logs(Store(connections.pool, writing=connections.writing), connections.signal)
346
+ # Pushed before everything that appends on its way out, so it runs after them and before the
347
+ # pool closes: every append a request was promised is written.
348
+ stack.push_async_callback(logs.store.writer.drained)
349
+ stack.push_async_callback(logs.close)
350
+ codes = Codes(logs)
351
+ sockets, live = Sockets(logs), ServedCalls(connections.signal)
352
+ await sockets.start()
353
+ stack.push_async_callback(sockets.close)
354
+ await live.listen()
355
+ stack.push_async_callback(live.quiet)
356
+ embedder = await embedder_of(connections)
357
+ serving = Serving(connections=connections, logs=logs, live=live, embedder=embedder)
358
+ threads = Threads(serving, sockets)
359
+ await threads.loaded()
360
+ await threads.start()
361
+ stack.push_async_callback(threads.closed)
362
+ roster = Roster(connections.signal)
363
+ await roster.start()
364
+ stack.push_async_callback(roster.close)
365
+ paced = Window(signal=connections.signal)
366
+ await paced.start()
367
+ stack.push_async_callback(paced.close)
368
+ outbox = Outbox(connections, _box_mailbox(settings))
369
+ stack.push_async_callback(outbox.drained)
370
+ remembered = RememberedKeys(connections.pool, connections.signal)
371
+ await remembered.start()
372
+ stack.push_async_callback(remembered.close)
373
+ return Gateway(
374
+ connections=connections,
375
+ logs=logs,
376
+ sockets=sockets,
377
+ live=live,
378
+ roster=roster,
379
+ codes=codes,
380
+ keys=remembered,
381
+ signer=Signer(settings.livekit_api_key, settings.livekit_api_secret, settings.token_key),
382
+ threads=threads,
383
+ closing=asyncio.Event(),
384
+ embedder=embedder,
385
+ signins=SignIns.kept(Words(connections.pool, connections.vault)),
386
+ samples=Throttle(connections.pool, SAMPLES_A_MINUTE),
387
+ paced=paced,
388
+ outbox=outbox,
389
+ evals=Runner(connections.pool),
390
+ )
391
+
392
+
393
+ # Set from the signal handler, through the loop: streams never end on their own, and a stop
394
+ # would otherwise wait out the grace period and cut them mid-frame.
395
+ def announce_closing(fastapi_app: FastAPI) -> None:
396
+ """Tell every open stream the process is stopping."""
397
+ gateway: object = getattr(fastapi_app.state, "gateway", None)
398
+ if isinstance(gateway, Gateway):
399
+ asyncio.get_running_loop().call_soon_threadsafe(gateway.closing.set)
400
+
401
+
402
+ async def refused(_request: Request, error: Exception) -> Response:
403
+ """A refusal answered with its status and its sentence."""
404
+ status = error.status if isinstance(error, PinecallError) else 500
405
+ headers = {"WWW-Authenticate": "Bearer"} if isinstance(error, NotSignedIn) else None
406
+ if isinstance(error, Throttled):
407
+ headers = {"Retry-After": str(error.retry_after_s)}
408
+ return JSONResponse({"detail": str(error)}, status_code=status, headers=headers)
409
+
410
+
411
+ async def busy(request: Request, error: Exception) -> Response:
412
+ """A full pool or a statement past its timeout, answered 503 with its one sentence."""
413
+ if isinstance(error, PoolTimeout):
414
+ return await refused(request, StoreUnreachable(NO_CONNECTION.format(wait=TIMEOUTS.wait_s)))
415
+ took = TIMEOUTS.statement_ms / 1000
416
+ return await refused(request, StoreUnreachable(TOO_SLOW.format(took=took)))
417
+
418
+
419
+ def origins_allowed(settings: Settings) -> tuple[str, ...]:
420
+ """The app's two WebViews, then PINECALL_APP_ORIGINS, each once."""
421
+ named = (item.strip() for item in settings.app_origins.split(","))
422
+ return tuple(dict.fromkeys((*THE_APPS_WEBVIEWS, *(item for item in named if item))))
423
+
424
+
425
+ def widget_file(file: str) -> FileResponse:
426
+ """A file of the widget, for any site to load as a module."""
427
+ root = (BUILT / "widget").resolve()
428
+ params = (root / file).resolve()
429
+ if root not in params.parents or not params.is_file():
430
+ raise HTTPException(404, "Not Found")
431
+ return FileResponse(params, headers=WIDGET_HEADERS)
432
+
433
+
434
+ # The last route: a built file is itself, any other path is the page, so a reload lands where it
435
+ # was (the page reads its world from the path: `/sandbox/…` is the sandbox's). Never cached: a
436
+ # rebuild names new hashed assets in a new index.html.
437
+ def console(path: str) -> Response:
438
+ """The console: an asset of its build, or its page."""
439
+ if path.startswith(API_PREFIXES):
440
+ raise HTTPException(404, "Not Found")
441
+ root = (BUILT / "console").resolve()
442
+ if not (root / THE_PAGE).is_file():
443
+ raise HTTPException(404, NOT_BUILT)
444
+ params = (root / path).resolve()
445
+ if path and root in params.parents and params.is_file() and params.name != THE_PAGE:
446
+ return FileResponse(params, headers=PAGE_HEADERS)
447
+ page = (root / THE_PAGE).read_text(encoding="utf-8")
448
+ return HTMLResponse(page, headers={**PAGE_HEADERS, "cache-control": "no-store"})
449
+
450
+
451
+ # One gateway's app: a test serves two, each with a gateway of its own in its state. Swagger under
452
+ # /v1: the console has a /docs screen of its own. The console is the last route: a built file is
453
+ # itself, any other path is the page.
454
+ def served_app() -> FastAPI:
455
+ """The gateway's doors, its refusals, the widget and the console, on a new app."""
456
+ served = FastAPI(
457
+ title="Pinecall gateway", lifespan=lifespan, docs_url="/v1/docs", redoc_url="/v1/redoc"
458
+ )
459
+ served.add_middleware(AppOrigins)
460
+ served.add_middleware(BodyCeiling)
461
+ for doors in ROUTERS:
462
+ served.include_router(doors.router)
463
+ served.add_exception_handler(PinecallError, refused)
464
+ served.add_exception_handler(PoolTimeout, busy)
465
+ served.add_exception_handler(QueryCanceled, busy)
466
+ served.add_api_route("/widget/{file}", widget_file, methods=["GET"], include_in_schema=False)
467
+ served.add_api_route("/{path:path}", console, methods=["GET"], include_in_schema=False)
468
+ return served
469
+
470
+
471
+ # A box with no embedder still starts: every door that embeds says what the operator must set.
472
+ async def embedder_of(connections: Connections) -> Embedder | None:
473
+ """The box's embedder from its providers row and key; None, and said, when it has none."""
474
+ try:
475
+ embedding = (await catalog.providers(connections.pool)).embedding
476
+ except NotAvailable:
477
+ logger.warning(NO_EMBEDDER)
478
+ return None
479
+ if embedding is None:
480
+ return None
481
+ key = (await box_credentials(connections.pool, connections.vault)).get(embedding.vendor)
482
+ if key is None:
483
+ logger.warning(NO_EMBEDDER_KEY, embedding.vendor)
484
+ return None
485
+ return Embedder(embedding, key, connections.http)
486
+
487
+
488
+ # A box without mail still starts: its letters are not sent, and /.well-known says so.
489
+ def _box_mailbox(settings: Settings) -> Mailbox | None:
490
+ if settings.smtp_url is None:
491
+ return None
492
+ try:
493
+ return parse_mailbox_url(settings.smtp_url, settings.mail_from or "")
494
+ except DeclarationRefused as unreadable:
495
+ logger.warning(NO_BOX_MAIL, unreadable)
496
+ return None
497
+
498
+
499
+ async def _cancelled[T](task: asyncio.Task[T]) -> None:
500
+ task.cancel()
501
+ await asyncio.gather(task, return_exceptions=True)
502
+
503
+
504
+ app = served_app()
File without changes
@@ -0,0 +1,63 @@
1
+ """A call handed to a socket: call.attached, then its pump; parked calls given to the next one."""
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from pinecall.domain.scope import Scope
6
+ from pinecall.gateway._served import CLAIMED, STARTED, ServedCalls
7
+ from pinecall.gateway._sockets import SocketId, Sockets
8
+ from pinecall.log.reduce import reduce
9
+ from pinecall.session.tools import unanswered
10
+ from pinecall.wire.events import CallAttached, CallStarted
11
+ from pinecall.wire.frames import Entry
12
+
13
+
14
+ async def attach(live: ServedCalls, call: str, app: SocketId) -> Entry | None:
15
+ """Give the call to this socket: call.attached first, then the tools still waiting."""
16
+ served = live.attach(call, app)
17
+ if served is None:
18
+ return None
19
+ entries = await served.log.whole()
20
+ started = next((entry for entry in entries if entry.type == STARTED), None)
21
+ if started is None:
22
+ # Not started yet: the socket hears it from what comes next.
23
+ live.pumped(call, after=entries[-1].seq if entries else 0)
24
+ return None
25
+ claimed = next(
26
+ (str(entry.data["code"]) for entry in reversed(entries) if entry.type == CLAIMED), None
27
+ )
28
+ data = CallAttached(
29
+ app=app,
30
+ started=CallStarted.model_validate(started.data),
31
+ state=reduce(entries).app_state,
32
+ seq=entries[-1].seq,
33
+ claimed=claimed,
34
+ )
35
+ entry = await served.log.append("call.attached", data.written())
36
+ # After call.attached, so the result lands on the call id still awaited: read off the log, since
37
+ # the worker may have asked them of another gateway.
38
+ live.pumped(call, after=entry.seq - 1, then=unanswered(entries))
39
+ return entry
40
+
41
+
42
+ async def parked_calls_of(live: ServedCalls, scope: Scope, slug: str, app: SocketId) -> None:
43
+ """Give every parked call of the agent in the scope to this socket."""
44
+ for call in live.parked(scope, slug):
45
+ await attach(live, call, app)
46
+
47
+
48
+ # Each call of a leaving socket goes where a new call would, or waits parked.
49
+ async def handed_on(live: ServedCalls, sockets: Sockets, calls: Iterable[str]) -> tuple[int, int]:
50
+ """Hand the calls on; how many were handed and how many parked."""
51
+ handed = parked = 0
52
+ for call in calls:
53
+ served = live.calls.get(call)
54
+ # An ended call waits for its seal, which needs no socket.
55
+ if served is None or (await served.log.snapshot()).status == "ended":
56
+ continue
57
+ taking = sockets.serving(served.scope, served.agent, None)
58
+ if taking is not None and await attach(live, call, taking.owner) is not None:
59
+ handed += 1
60
+ else:
61
+ live.attach(call, None)
62
+ parked += 1
63
+ return handed, parked
@@ -0,0 +1,18 @@
1
+ """The app's and the desk's commands for a call, told to whichever gateway runs it."""
2
+
3
+ from pinecall.process.signal import Signal
4
+ from pinecall.wire.frames import Command
5
+
6
+ # One channel per call: every gateway that serves the call listens on it while it does.
7
+ COMMANDS_CHANNEL = "cmd:{call}"
8
+
9
+ # The one verb a desk sends, which a written call's session takes as a supervisor's.
10
+ SUPERVISOR_VERB = "supervisor.verb"
11
+
12
+
13
+ # Lossy, as today's queue is on a gateway that dies: nobody hearing it is the refusal the sender
14
+ # gives, and a Redis that is away is a NotAvailable the app can try again after.
15
+ async def commanded(signal: Signal, command: Command) -> bool:
16
+ """Tell the gateways that serve the call; False when none does."""
17
+ channel = COMMANDS_CHANNEL.format(call=command.call)
18
+ return await signal.published(channel, command.model_dump_json().encode()) > 0
@@ -0,0 +1,100 @@
1
+ """What one gateway sends an app socket another holds, the answers back, and who holds which."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pydantic import BaseModel, TypeAdapter
6
+
7
+ from pinecall.domain.errors import NotAvailable
8
+ from pinecall.domain.scope import Scope
9
+ from pinecall.process.shared import Shared
10
+ from pinecall.process.signal import Signal
11
+ from pinecall.wire.commands import DevAnswer
12
+ from pinecall.wire.frames import Entry
13
+
14
+ # Each app socket's inbox, listened to by the gateway holding it while it is open.
15
+ APP_CHANNEL = "app:{app}"
16
+
17
+ # Where a dev.request's answer goes back to the gateway that asked it.
18
+ ANSWER_CHANNEL = "answer:{id}"
19
+
20
+ # Every gateway says the app sockets it holds here, for the org's list of apps.
21
+ SOCKETS_CHANNEL = "sockets"
22
+
23
+
24
+ class Bound(BaseModel):
25
+ """A call bound to an app socket another gateway holds, and the seq its pump starts after."""
26
+
27
+ call: str
28
+ after: int
29
+
30
+
31
+ class ForApp(BaseModel):
32
+ """One message for an app socket: a call bound to it, an entry to send it, or a stop."""
33
+
34
+ bound: Bound | None = None
35
+ entry: Entry | None = None
36
+ stop: str | None = None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SocketRow:
41
+ """An app socket another gateway holds, as the org's list of apps shows it."""
42
+
43
+ app: str
44
+ scope: Scope
45
+ address: str | None
46
+ connected_at: float
47
+ host: str | None = None
48
+
49
+
50
+ class Elsewhere:
51
+ """The app sockets the other gateways hold, as each says them."""
52
+
53
+ def __init__(self, signal: Signal) -> None:
54
+ """Nothing heard yet."""
55
+ self.shared = Shared(signal, SOCKETS_CHANNEL, _ROWS, (), lambda: None)
56
+
57
+ async def start(self) -> None:
58
+ """Hear the others, and say this gateway's."""
59
+ await self.shared.start()
60
+
61
+ async def close(self) -> None:
62
+ """Stop hearing and saying."""
63
+ await self.shared.close()
64
+
65
+ def rows_of(self, org: str, env: str) -> list[SocketRow]:
66
+ """The org's sockets in the world on every other gateway."""
67
+ return [
68
+ row
69
+ for heard in self.shared.theirs.values()
70
+ for row in heard.share
71
+ if row.scope.org == org and row.scope.env == env
72
+ ]
73
+
74
+
75
+ _ROWS: TypeAdapter[tuple[SocketRow, ...]] = TypeAdapter(tuple[SocketRow, ...])
76
+
77
+
78
+ # Lossy, as the signal is: a bound call whose notice was lost is told again when its worker asks
79
+ # a tool, and parked calls are handed on at every pass.
80
+ def told_bound(signal: Signal, app: str, bound: Bound) -> None:
81
+ """Tell the gateway holding the socket that the call is bound to it, from this seq."""
82
+ signal.publish(APP_CHANNEL.format(app=app), ForApp(bound=bound).model_dump_json().encode())
83
+
84
+
85
+ async def told_app(signal: Signal, app: str, message: ForApp) -> bool:
86
+ """Hand the message to the gateway holding the socket; False when none does."""
87
+ channel = APP_CHANNEL.format(app=app)
88
+ try:
89
+ return await signal.published(channel, message.model_dump_json().encode()) > 0
90
+ except NotAvailable:
91
+ return False
92
+
93
+
94
+ async def answered_back(signal: Signal, answer: DevAnswer) -> bool:
95
+ """Hand a dev.answer to the gateway that asked it; False when none waits."""
96
+ channel = ANSWER_CHANNEL.format(id=answer.id)
97
+ try:
98
+ return await signal.published(channel, answer.model_dump_json().encode()) > 0
99
+ except NotAvailable:
100
+ return False