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,569 @@
1
+ """The worker's one door to the platform: the gateway over HTTP, on its fleet's key."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ import logging
7
+ import time
8
+ from collections.abc import (
9
+ AsyncGenerator,
10
+ AsyncIterator,
11
+ Awaitable,
12
+ Callable,
13
+ Iterator,
14
+ Mapping,
15
+ Sequence,
16
+ )
17
+
18
+ import httpx
19
+ from opentelemetry import propagate
20
+ from pydantic import TypeAdapter
21
+ from websockets.asyncio.client import ClientConnection, connect
22
+ from websockets.exceptions import ConnectionClosed, InvalidHandshake
23
+
24
+ from pinecall.domain.agent import AgentConfig
25
+ from pinecall.domain.call import Route
26
+ from pinecall.domain.errors import GatewayRefused
27
+ from pinecall.domain.names import Channel, Json, JsonObject
28
+ from pinecall.domain.scope import Scope
29
+ from pinecall.wire.frames import Command, Entry
30
+ from pinecall.wire.parts import PlatformTool, ToolResult
31
+ from pinecall.wire.rest.agents import HoldAudio, RingHandoff
32
+ from pinecall.wire.rest.calls import (
33
+ AppendEntriesRefused,
34
+ AppendEntriesRequest,
35
+ AppendEntriesResponse,
36
+ BatchedEntry,
37
+ CallbackRequest,
38
+ LogPage,
39
+ OpenCallRequest,
40
+ OpenCallResponse,
41
+ RecordingKeyResponse,
42
+ SealCallRequest,
43
+ )
44
+ from pinecall.wire.rest.fleet import HeartbeatRequest, HeartbeatResponse
45
+ from pinecall.wire.rest.numbers import LegTrunk, LegTrunkResponse
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ # A caller is waiting on the line.
51
+ TIMEOUT_S = 5.0
52
+
53
+
54
+ # A clip is a few hundred KB, fetched once per box.
55
+ A_FILE_S = 15.0
56
+
57
+
58
+ # Under the job's sealing budget (worker/main.py); what does not seal here the reaper seals. One
59
+ # knock waits all of it: a seal writes memory and asks the judges, which outlast TIMEOUT_S.
60
+ SEALED_WITHIN_S = 30.0
61
+
62
+ # The header a balancer keeps a call's requests on one gateway by: `/v1/calls/{call}/...`.
63
+ CALL_HEADER = "Pinecall-Call"
64
+ CALL_AT = 3
65
+
66
+
67
+ # A restarting gateway is back in seconds; the cap keeps the log catching up after it.
68
+ FIRST_WAIT_S = 0.5
69
+
70
+
71
+ LONGEST_WAIT_S = 5.0
72
+
73
+
74
+ SAID_EVERY = 10
75
+
76
+
77
+ NOT_SERVED = 404
78
+
79
+
80
+ ALREADY_OVER = 409
81
+
82
+
83
+ # A caller is waiting on the line while the open is asked again.
84
+ OPENED_WITHIN_S = 15.0
85
+
86
+
87
+ # The socket a call's batches go down: `WS /v1/calls/{call}/entries`.
88
+ STREAM_DOOR = "/v1/calls/{call}/entries"
89
+
90
+
91
+ EVENT_STREAM = "text/event-stream"
92
+
93
+
94
+ _ROUTES: TypeAdapter[list[Route]] = TypeAdapter(list[Route])
95
+
96
+
97
+ _CONFIG: TypeAdapter[AgentConfig] = TypeAdapter(AgentConfig)
98
+
99
+
100
+ _OBJECT: TypeAdapter[JsonObject] = TypeAdapter(JsonObject)
101
+
102
+
103
+ class GatewayClient:
104
+ """The gateway as a worker asks it: every door it knocks, on one connection pool."""
105
+
106
+ def __init__(self, http: httpx.AsyncClient) -> None:
107
+ """No call opened yet."""
108
+ self.http = http
109
+ # What each unsealed call was opened with, said again to a gateway that restarted.
110
+ self.opened: dict[str, OpenCallRequest] = {}
111
+ # Each unsealed call's socket for its batches; none once a gateway had no such door.
112
+ self.streams: dict[str, EntriesStream] = {}
113
+ self.doorless = False
114
+
115
+ # ── an agent, before its call ──
116
+
117
+ async def routes(
118
+ self, scope: Scope | None, *, number: str | None, channel: Channel
119
+ ) -> list[Route]:
120
+ """The routes of the dispatch's scope; with no scope, the one a number rings, any org."""
121
+ params = _scope_headers(scope)
122
+ if number is not None:
123
+ params |= {"number": number, "channel": channel}
124
+ return _ROUTES.validate_python(await self._read("GET", "/v1/routes", params=params))
125
+
126
+ # The call, not opened yet, is named so a canary's share is picked by its id.
127
+ async def agent(self, slug: str, scope: Scope, *, call: str | None = None) -> AgentConfig:
128
+ """The agent as the scope runs it for the call: its declaration under the settings."""
129
+ params = _scope_headers(scope) | ({} if call is None else {"for_call": call})
130
+ text = await self._read("GET", f"/v1/agents/{slug}/config", params=params)
131
+ return _CONFIG.validate_python(text)
132
+
133
+ # The one answer that carries keys, and it carries them to the fleet's key alone.
134
+ async def stages(self, slug: str, scope: Scope, *, call: str | None = None) -> Json:
135
+ """The three stages the call runs, each with the key it runs on."""
136
+ params = _scope_headers(scope) | ({} if call is None else {"for_call": call})
137
+ return await self._read("GET", f"/v1/agents/{slug}/provider-keys", params=params)
138
+
139
+ async def hold_audio(self, slug: str, scope: Scope) -> HoldAudio:
140
+ """What the agent plays while a tool runs."""
141
+ text = await self._read(
142
+ "GET", f"/v1/agents/{slug}/hold-audio", params=_scope_headers(scope)
143
+ )
144
+ return HoldAudio.model_validate(text)
145
+
146
+ async def hold_clip(self, slug: str, scope: Scope) -> bytes:
147
+ """The org's own clip, Ogg Opus."""
148
+ path = f"/v1/agents/{slug}/hold-audio/audio"
149
+ answer = await self._answered("GET", path, None, A_FILE_S, _scope_headers(scope))
150
+ return answer.content
151
+
152
+ async def rings_for(self, slug: str, org: str, caller: str) -> RingHandoff:
153
+ """Whether a production ring from this phone belongs in a developer's sandbox."""
154
+ params = {"org": org, "caller": caller}
155
+ text = await self._read("GET", f"/v1/agents/{slug}/rings-for", params=params)
156
+ return RingHandoff.model_validate(text)
157
+
158
+ # The gateway judges the leg (shape and pace) and names the trunk; a refusal is its sentence.
159
+ async def leg(
160
+ self, slug: str, scope: Scope, *, to: str, call: str, shown: str | None
161
+ ) -> LegTrunk:
162
+ """How to dial a leg of the call: the trunk inline and the number shown."""
163
+ params = (
164
+ _scope_headers(scope) | {"to": to, "call": call} | ({"from": shown} if shown else {})
165
+ )
166
+ text = await self._read("GET", f"/v1/agents/{slug}/outbound-trunk", params=params)
167
+ return LegTrunkResponse.model_validate(text).trunk
168
+
169
+ # A cold transfer dials nothing of ours, so no trunk: the gateway judges the leg alone.
170
+ async def sent_on(self, slug: str, scope: Scope, *, to: str, call: str) -> None:
171
+ """Have the gateway judge a cold transfer's leg; its refusal is its sentence."""
172
+ params = _scope_headers(scope) | {"to": to, "call": call}
173
+ await self._read("POST", f"/v1/agents/{slug}/cold-transfer", params=params)
174
+
175
+ # ── a call ──
176
+
177
+ # Asked again while the gateway is away: an open is the same call by its id (one claim, one
178
+ # opening, one ringing), so an answer lost with a gateway that died is not a call lost.
179
+ async def open(self, opening: OpenCallRequest) -> OpenCallResponse:
180
+ """Open the call's log; the answer is what the org's minutes leave it."""
181
+ body = opening.written()
182
+ data = await again(lambda: self._read("POST", "/v1/calls", body), OPENED_WITHIN_S, "open")
183
+ self.opened[opening.context.call] = opening
184
+ return OpenCallResponse.model_validate(data)
185
+
186
+ # Retried without a limit, the same batch after the same count: the log answers a batch it
187
+ # already took with the seqs it gave it, so an answer lost never writes an entry twice. The
188
+ # batches go down one socket per call; a gateway of before has no such door, and from the
189
+ # first that says so every call's batches go by request.
190
+ async def append_many(
191
+ self, call: str, entries: Sequence[BatchedEntry], *, after: int
192
+ ) -> list[Entry]:
193
+ """Write a batch of the call's entries once, in order; the gateway numbers them."""
194
+ path = STREAM_DOOR.format(call=call)
195
+ if not self.doorless:
196
+ stream = self.streams.get(call) or self._stream_of(call)
197
+ numbered = await again(
198
+ lambda: self._streamed_batch(call, stream, entries, after), None, path
199
+ )
200
+ if numbered is not None:
201
+ return numbered
202
+ self.doorless = True
203
+ self.streams.pop(call, None)
204
+ body = AppendEntriesRequest(after=after, entries=list(entries)).written()
205
+ door = f"/v1/calls/{call}/entries"
206
+ answer = await again(lambda: self._on_the_call(call, "POST", door, body), None, door)
207
+ return AppendEntriesResponse.model_validate(answer).entries
208
+
209
+ # The gateway answers a lapsed tool at its own deadline: wait past it, so the model reads
210
+ # that answer and not a timeout of ours. Idempotent per call id, so a retry joins.
211
+ async def tool(self, call: str, agent: str, called: JsonObject, timeout_s: float) -> ToolResult:
212
+ """Run a tool in the app that declared it and return what it answered."""
213
+ waiting = timeout_s + TIMEOUT_S
214
+ path = f"/v1/calls/{call}/tools?agent={agent}"
215
+ answer = await again(
216
+ lambda: self._on_the_call(call, "POST", path, called, waiting), waiting, path
217
+ )
218
+ return ToolResult.model_validate(answer)
219
+
220
+ async def sealed(self, call: str, sealing: SealCallRequest) -> None:
221
+ """Hand the end of the call to the gateway, which prices, judges and seals it."""
222
+ path = f"/v1/calls/{call}/sealed"
223
+ body = sealing.written()
224
+ try:
225
+ await again(
226
+ lambda: self._on_the_call(call, "POST", path, body, SEALED_WITHIN_S),
227
+ SEALED_WITHIN_S,
228
+ path,
229
+ )
230
+ except GatewayRefused as refused:
231
+ # Sealed already: by this seal, whose answer a gateway that died took with it.
232
+ if refused.answered != ALREADY_OVER:
233
+ raise
234
+ self.opened.pop(call, None)
235
+ await self._stream_closed(call)
236
+
237
+ async def lookup(
238
+ self, call: str, tool: PlatformTool, arguments: Mapping[str, Json], speech: str | None
239
+ ) -> JsonObject:
240
+ """Recall or search, run by the gateway, which writes what it found on the log."""
241
+ data: JsonObject = {"tool": tool, "input": dict(arguments), "speech_id": speech}
242
+ answer = _OBJECT.validate_python(
243
+ await self._on_the_call(call, "POST", f"/v1/calls/{call}/lookup", data)
244
+ )
245
+ return _OBJECT.validate_python(answer.get("output") or {})
246
+
247
+ # Asked once, at the call's end: a gateway away leaves the recording plain, never unsealed
248
+ # waiting. A gateway of before sealed recordings has no such door and answers 404.
249
+ async def recording_key(self, call: str) -> bytes | None:
250
+ """The key the call's recording is sealed under; None from a gateway that seals none."""
251
+ try:
252
+ answer = await self._on_the_call(call, "POST", f"/v1/calls/{call}/recording/key")
253
+ except GatewayRefused as refused:
254
+ if refused.answered != NOT_SERVED:
255
+ raise
256
+ return None
257
+ return base64.urlsafe_b64decode(RecordingKeyResponse.model_validate(answer).key)
258
+
259
+ # A code nobody issued is an ordinary answer: the caller may be dialling an extension.
260
+ async def claim(self, call: str, code: str) -> bool:
261
+ """Tie the call to the page showing the code; False when no page waits on it."""
262
+ try:
263
+ await self._on_the_call(call, "POST", f"/v1/calls/{call}/claim", {"code": code})
264
+ except GatewayRefused as refused:
265
+ if refused.answered != NOT_SERVED:
266
+ raise
267
+ return False
268
+ return True
269
+
270
+ async def state(self, call: str) -> JsonObject:
271
+ """The call's state as the gateway folds it, with the seq it was folded to."""
272
+ return _OBJECT.validate_python(
273
+ await self._on_the_call(call, "GET", f"/v1/calls/{call}/state")
274
+ )
275
+
276
+ async def since(self, call: str, after: int) -> AsyncIterator[Entry]:
277
+ """The stored entries above the cursor, page by page, until the log's end."""
278
+ cursor = after
279
+ while True:
280
+ path = f"/v1/calls/{call}/events?after={cursor}"
281
+ page = await again(lambda p=path: self._read("GET", p), SEALED_WITHIN_S, path)
282
+ if page is None:
283
+ return
284
+ read = LogPage.model_validate(page)
285
+ for entry in read.entries:
286
+ yield Entry.model_validate(entry)
287
+ if read.next is None:
288
+ return
289
+ cursor = read.next
290
+
291
+ async def tail(self, call: str, after: int) -> AsyncIterator[Entry]:
292
+ """The entries above the cursor, then the live ones, until the call ends."""
293
+ async for frame in self._streamed(f"/v1/calls/{call}/events?after={after}"):
294
+ yield Entry.model_validate(frame)
295
+
296
+ # A stream that ends means the gateway went away; a 404 means it forgot the call.
297
+ async def commands(self, call: str) -> AsyncGenerator[Command]:
298
+ """The app's commands for the call, in the order it sent them, until the call is sealed."""
299
+ path = f"/v1/calls/{call}/commands"
300
+ try:
301
+ async for frame in self._streamed(path):
302
+ yield Command.model_validate(frame)
303
+ except GatewayRefused as refused:
304
+ if not await self._reopened(call, refused):
305
+ raise
306
+ else:
307
+ return
308
+ async for frame in self._streamed(path):
309
+ yield Command.model_validate(frame)
310
+
311
+ # ── the fleet ──
312
+
313
+ async def heartbeat(self, beat: HeartbeatRequest) -> HeartbeatResponse:
314
+ """Report this worker; the answer says whether it is cordoned."""
315
+ return HeartbeatResponse.model_validate(
316
+ await self._read("POST", "/v1/fleet/heartbeat", beat.written())
317
+ )
318
+
319
+ async def callback(self, wanted: CallbackRequest) -> None:
320
+ """Somebody the overflow told to wait for a call back."""
321
+ await self._read("POST", "/v1/callbacks", wanted.written())
322
+
323
+ async def aclose(self) -> None:
324
+ """Close every call's socket and the connection pool."""
325
+ for call in list(self.streams):
326
+ await self._stream_closed(call)
327
+ await self.http.aclose()
328
+
329
+ # ── the wire ──
330
+
331
+ async def _read(
332
+ self,
333
+ method: str,
334
+ path: str,
335
+ data: Json = None,
336
+ wait_s: float | None = None,
337
+ params: Mapping[str, str] | None = None,
338
+ ) -> Json:
339
+ answer = await self._answered(method, path, data, wait_s, params)
340
+ return answer.json() if answer.content else None
341
+
342
+ async def _answered(
343
+ self,
344
+ method: str,
345
+ path: str,
346
+ data: Json,
347
+ wait_s: float | None,
348
+ params: Mapping[str, str] | None,
349
+ ) -> httpx.Response:
350
+ try:
351
+ answer = await self.http.request(
352
+ method,
353
+ path,
354
+ json=data,
355
+ timeout=wait_s or TIMEOUT_S,
356
+ params=params or None,
357
+ headers={**traced(), **affine(path)},
358
+ )
359
+ except httpx.HTTPError as unreachable:
360
+ raise GatewayRefused(f"{method} {path}: {unreachable}") from unreachable
361
+ if answer.status_code >= httpx.codes.BAD_REQUEST:
362
+ raise GatewayRefused(
363
+ f"{method} {path}: {answer.status_code} {answer.text}", answered=answer.status_code
364
+ )
365
+ return answer
366
+
367
+ # A 404 for a call this worker opened is a gateway that restarted: say the call again, once.
368
+ async def _on_the_call(
369
+ self, call: str, method: str, path: str, data: Json = None, wait_s: float | None = None
370
+ ) -> Json:
371
+ try:
372
+ return await self._read(method, path, data, wait_s)
373
+ except GatewayRefused as refused:
374
+ if not await self._reopened(call, refused):
375
+ raise
376
+ return await self._read(method, path, data, wait_s)
377
+
378
+ def _stream_of(self, call: str) -> "EntriesStream":
379
+ path = STREAM_DOOR.format(call=call)
380
+ base = str(self.http.base_url).rstrip("/")
381
+ url = f"ws{base.removeprefix('http')}{path}"
382
+ # The key alone of the pool's headers: its keep-alive and encodings are not a handshake's.
383
+ bearer = self.http.headers.get("Authorization")
384
+ headers = {**({"Authorization": bearer} if bearer else {}), **traced(), **affine(path)}
385
+ stream = EntriesStream(url, headers, call)
386
+ self.streams[call] = stream
387
+ return stream
388
+
389
+ # A 404 on the socket is the gateway that forgot the call, as on a request; None is a gateway
390
+ # with no such door at all, which the batch door serves.
391
+ async def _streamed_batch(
392
+ self, call: str, stream: "EntriesStream", entries: Sequence[BatchedEntry], after: int
393
+ ) -> list[Entry] | None:
394
+ try:
395
+ return await stream.append_many(entries, after=after)
396
+ except GatewayRefused as refused:
397
+ if stream.doorless:
398
+ return None
399
+ if not await self._reopened(call, refused):
400
+ raise
401
+ return await stream.append_many(entries, after=after)
402
+
403
+ async def _stream_closed(self, call: str) -> None:
404
+ stream = self.streams.pop(call, None)
405
+ if stream is not None:
406
+ await stream.close()
407
+
408
+ async def _reopened(self, call: str, refused: GatewayRefused) -> bool:
409
+ opening = self.opened.get(call)
410
+ if refused.answered != NOT_SERVED or opening is None:
411
+ return False
412
+ await self._read("POST", f"/v1/calls/{call}/reopened", opening.written())
413
+ return True
414
+
415
+ async def _streamed(self, path: str) -> AsyncIterator[JsonObject]:
416
+ try:
417
+ async with self.http.stream(
418
+ "GET", path, headers={"Accept": EVENT_STREAM, **affine(path)}, timeout=STREAMED
419
+ ) as stream:
420
+ if stream.status_code >= httpx.codes.BAD_REQUEST:
421
+ raise GatewayRefused(
422
+ f"GET {path}: {stream.status_code}", answered=stream.status_code
423
+ )
424
+ async for frame in server_sent(stream.aiter_lines()):
425
+ yield frame
426
+ except httpx.HTTPError as unreachable:
427
+ raise GatewayRefused(f"GET {path}: {unreachable}") from unreachable
428
+
429
+
430
+ # One batch is out at a time (the call's writer sends the next when this one is answered), so an
431
+ # answer is the last frame sent. An answer that does not come within the request's timeout is
432
+ # not waited for past it: the socket is dropped, and the retry asks again on a new one.
433
+ class EntriesStream:
434
+ """A call's batches down one WebSocket to the gateway, each answered with its seqs."""
435
+
436
+ def __init__(self, url: str, headers: Mapping[str, str], call: str) -> None:
437
+ """Not connected yet: the first batch connects."""
438
+ self.url = url
439
+ self.headers = dict(headers)
440
+ self.call = call
441
+ self.socket: ClientConnection | None = None
442
+ # True once the gateway refused the upgrade: it has no such door.
443
+ self.doorless = False
444
+ self._sending = asyncio.Lock()
445
+
446
+ async def append_many(self, entries: Sequence[BatchedEntry], *, after: int) -> list[Entry]:
447
+ """Send the batch and read its answer; GatewayRefused when the socket or the log did."""
448
+ frame = AppendEntriesRequest(after=after, entries=list(entries)).model_dump_json()
449
+ async with self._sending:
450
+ socket = await self._connected()
451
+ try:
452
+ await socket.send(frame)
453
+ raw = await asyncio.wait_for(socket.recv(), TIMEOUT_S)
454
+ except (ConnectionClosed, OSError, TimeoutError) as lost:
455
+ await self.close()
456
+ raise GatewayRefused(f"WS {self.url}: {lost}") from lost
457
+ answer = json.loads(raw)
458
+ if isinstance(answer, dict) and "refused" in answer:
459
+ refused = AppendEntriesRefused.model_validate(answer)
460
+ raise GatewayRefused(
461
+ f"WS {self.url}: {refused.status} {refused.refused}", answered=refused.status
462
+ )
463
+ return AppendEntriesResponse.model_validate(answer).entries
464
+
465
+ async def close(self) -> None:
466
+ """Close the socket; the next batch opens another."""
467
+ socket, self.socket = self.socket, None
468
+ if socket is not None:
469
+ await socket.close()
470
+
471
+ async def _connected(self) -> ClientConnection:
472
+ if self.socket is not None:
473
+ return self.socket
474
+ try:
475
+ self.socket = await connect(
476
+ self.url, additional_headers=self.headers, open_timeout=TIMEOUT_S
477
+ )
478
+ except InvalidHandshake as refused:
479
+ self.doorless = True
480
+ raise GatewayRefused(f"WS {self.url}: {refused}", answered=NOT_SERVED) from refused
481
+ except (OSError, TimeoutError) as unreachable:
482
+ raise GatewayRefused(f"WS {self.url}: {unreachable}") from unreachable
483
+ return self.socket
484
+
485
+
486
+ # A stream is timed only on connect: a quiet one is kept open by the gateway's ping every 25 s.
487
+ STREAMED = httpx.Timeout(TIMEOUT_S, read=None)
488
+
489
+
490
+ def gateway_at(url: str, key: str | None) -> GatewayClient:
491
+ """The gateway at this URL, knocked on with the fleet's key in the header, never the URL."""
492
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
493
+ return GatewayClient(httpx.AsyncClient(base_url=url, headers=headers, timeout=TIMEOUT_S))
494
+
495
+
496
+ # `data:` holds the whole entry, its seq and type included: `id:`, `event:` and comments are
497
+ # not read.
498
+ async def server_sent(lines: AsyncIterator[str]) -> AsyncIterator[JsonObject]:
499
+ """Each JSON message of a server-sent event stream."""
500
+ data: list[str] = []
501
+ async for raw in lines:
502
+ line = raw.rstrip("\r\n")
503
+ if line.startswith("data:"):
504
+ data.append(line[5:].lstrip(" "))
505
+ elif not line and data:
506
+ text = json.loads("\n".join(data))
507
+ data = []
508
+ if isinstance(text, dict):
509
+ yield text
510
+
511
+
512
+ def waits() -> Iterator[float]:
513
+ """The waits between tries: doubling from half a second to five."""
514
+ wait = FIRST_WAIT_S
515
+ while True:
516
+ yield wait
517
+ wait = min(wait * 2, LONGEST_WAIT_S)
518
+
519
+
520
+ def away(refused: GatewayRefused) -> bool:
521
+ """Whether the gateway is away (unreachable or a 5xx), which a retry outlasts."""
522
+ return refused.answered is None or refused.answered >= httpx.codes.INTERNAL_SERVER_ERROR
523
+
524
+
525
+ # A 4xx is an answer. `within_s` bounds a request that has a deadline (a tool); None retries for
526
+ # as long as the call lasts (an entry).
527
+ async def again[T](attempt: Callable[[], Awaitable[T]], within_s: float | None, what: str) -> T:
528
+ """Run the attempt, retrying while the gateway is away and there is time left."""
529
+ started = time.monotonic()
530
+ pauses = waits()
531
+ tries = 0
532
+ while True:
533
+ tries += 1
534
+ try:
535
+ return await attempt()
536
+ except GatewayRefused as refused:
537
+ pause = next(pauses)
538
+ late = within_s is not None and time.monotonic() - started + pause > within_s
539
+ if not away(refused) or late:
540
+ raise
541
+ if tries % SAID_EVERY == 0:
542
+ logger.warning("%s: the gateway is still away after %d tries", what, tries)
543
+ await asyncio.sleep(pause)
544
+
545
+
546
+ # The worker's current span rides each request as W3C traceparent, so the gateway's handling of an
547
+ # append joins the turn's trace; with no tracer set (no PINECALL_OTLP_ENDPOINT) nothing is added.
548
+ def traced() -> dict[str, str]:
549
+ """The headers that carry the current trace to the gateway; none when nothing traces."""
550
+ carried: dict[str, str] = {}
551
+ propagate.inject(carried)
552
+ return carried
553
+
554
+
555
+ # A box of several gateways hashes this header at its balancer, so a call's doors land on one
556
+ # gateway while it lives: its first sights, its sequencer's fills and its writers stay rare. Only
557
+ # a speed-up: any gateway serves any door, and an old worker sends none.
558
+ def affine(path: str) -> dict[str, str]:
559
+ """The header naming the call a door is about, when its path names one."""
560
+ parts = path.split("/")
561
+ if len(parts) > CALL_AT and parts[1:CALL_AT] == ["v1", "calls"] and parts[CALL_AT]:
562
+ return {CALL_HEADER: parts[CALL_AT]}
563
+ return {}
564
+
565
+
566
+ def _scope_headers(scope: Scope | None) -> dict[str, str]:
567
+ if scope is None:
568
+ return {}
569
+ return {"org": scope.org, "env": scope.env, "holder": scope.holder}
@@ -0,0 +1,51 @@
1
+ """How many scaled workers a fleet wants: the one rule Kubernetes sizes the burst by."""
2
+
3
+ import math
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+ from fractions import Fraction
7
+
8
+ from pinecall.fleet.roster import heard_lately
9
+ from pinecall.wire.rest.fleet import WorkerStatus
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Line:
14
+ """Where a fleet is kept: the busy target, the slack under it, how many, and their seats."""
15
+
16
+ # Grow above `target`; shrink only below `target - slack`, so two asks do not oscillate.
17
+ target: float = 0.6
18
+ slack: float = 0.15
19
+ at_least: int = 0
20
+ at_most: int = 10
21
+ # PINECALL_MAX_JOBS of a scaled worker: what one more of them adds.
22
+ seats_per_worker: int = 4
23
+
24
+
25
+ # A cluster's workers that scale (their names start with `scaled`) are sized by Kubernetes, which
26
+ # asks this number, counting the seats of the workers that do not scale (a core node's) first, so
27
+ # a call they hold asks for no scaled one. Grow to what brings busy under the target; let one go
28
+ # only when busy stays under target - slack without it; else hold. Absolute, so a pod still
29
+ # booting, not yet heard, is never asked for twice.
30
+ def wanted_scaled(
31
+ seats: Sequence[WorkerStatus], scaled: str, line: Line, now: float
32
+ ) -> tuple[int, int, int]:
33
+ """How many scaled workers the fleet wants now, its calls, and the seats it holds."""
34
+ counted = [seat for seat in seats if heard_lately(seat, now) and seat.max_jobs is not None]
35
+ holding = [seat for seat in counted if not seat.cordoned]
36
+ active = sum(seat.active for seat in counted)
37
+ fixed = sum(seat.max_jobs or 0 for seat in holding if not seat.worker.startswith(scaled))
38
+ current = sum(1 for seat in holding if seat.worker.startswith(scaled))
39
+ each = line.seats_per_worker
40
+ # The target's decimal as written: 6 / 0.6 in floats is 10.000000000000002, one seat too many.
41
+ needed = math.ceil(Fraction(active) / Fraction(str(line.target))) - fixed
42
+ grown = max(0, math.ceil(Fraction(needed) / each)) if needed > 0 else 0
43
+ capacity = fixed + current * each
44
+ if grown > current:
45
+ return min(grown, line.at_most), active, capacity
46
+ left = fixed + (current - 1) * each
47
+ # With no call one may always go; with calls, only if the seats left keep busy under the slack.
48
+ quiet = active == 0 or (left > 0 and active / left < line.target - line.slack)
49
+ if current > max(grown, line.at_least) and quiet:
50
+ return current - 1, active, capacity
51
+ return current, active, capacity