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,194 @@
1
+ """Beside a worker's calls: the load livekit reads, and the heartbeat the gateway hears."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import socket
6
+ import time
7
+ from collections.abc import Callable
8
+ from dataclasses import fields
9
+
10
+ from livekit.agents import AgentServer
11
+ from livekit.agents.worker import ServerOptions
12
+
13
+ from pinecall.domain.errors import DeclarationRefused, GatewayRefused
14
+ from pinecall.fleet.client import GatewayClient
15
+ from pinecall.fleet.measures import LastMinute
16
+ from pinecall.fleet.roster import HEARTBEAT_S, REFUSED_AT, agent_name
17
+ from pinecall.process.settings import Settings
18
+ from pinecall.wire.rest.fleet import HeartbeatRequest
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ NO_MEASURE = "livekit's load_fnc option no longer carries a measure of its own"
24
+
25
+
26
+ # A worker that starts again under the same name (a pod's container, after its node was reset)
27
+ # registers anew while LiveKit may hold the last process's dead socket for 15-20 minutes and offer
28
+ # it calls. This process's start, to the second, keeps the two apart; the roster keys by the
29
+ # worker's name, so the gateway offers only the newest registration.
30
+ STARTED = f"{time.time():.0f}"
31
+
32
+
33
+ A_SLOT_AT_LEAST = "a worker holds at least one call: PINECALL_MAX_JOBS={max_jobs}"
34
+
35
+
36
+ # The id livekit's AgentServer holds until LiveKit answers its registration with the real one.
37
+ UNREGISTERED = "unregistered"
38
+
39
+
40
+ # The exit a cordoned worker leaves with; its unit's RestartPreventExitStatus= keeps it down.
41
+ CORDONED_EXIT = 3
42
+
43
+
44
+ # What LiveKit reads, on LiveKit's scale: its server offers a job under REFUSED_AT and its
45
+ # framework declines at it, so a worker that counts its calls is at REFUSED_AT with every slot
46
+ # taken. The server reads the load the worker last reported (every 2.5 s): a burst faster than
47
+ # that, on a worker's last slots, is declined by the framework and offered to no one else.
48
+ class Load:
49
+ """The load LiveKit reads: its line times the slots taken, or the machine's CPU share."""
50
+
51
+ def __init__(
52
+ self, max_jobs: int | None, measure: Callable[[AgentServer], float] | None = None
53
+ ) -> None:
54
+ """A worker nobody has dispatched to yet."""
55
+ if max_jobs is not None and max_jobs < 1:
56
+ raise DeclarationRefused(A_SLOT_AT_LEAST.format(max_jobs=max_jobs))
57
+ self.max_jobs = max_jobs
58
+ self.measure = measure or livekits_measure()
59
+ self.refused = False
60
+
61
+ def __call__(self, server: AgentServer) -> float:
62
+ """The load now, as livekit reads it."""
63
+ if self.max_jobs is None:
64
+ return self.announced(self.measure(server))
65
+ return self.at(len(server.active_jobs))
66
+
67
+ def at(self, active: int) -> float:
68
+ """The load of a worker holding this many calls, when its slots were measured."""
69
+ return self.announced(REFUSED_AT * active / (self.max_jobs or 1))
70
+
71
+ def announced(self, load: float) -> float:
72
+ """The load, a crossing of LiveKit's line said once each way."""
73
+ refused = load >= REFUSED_AT
74
+ if refused != self.refused:
75
+ self.refused = refused
76
+ if refused:
77
+ logger.warning("load %.2f: livekit routes no job here until it falls", load)
78
+ else:
79
+ logger.info("load %.2f: livekit routes jobs here again", load)
80
+ return load
81
+
82
+
83
+ class Heartbeats:
84
+ """The worker's report to its gateway every five seconds, and a cordon's way out."""
85
+
86
+ def __init__(
87
+ self, server: AgentServer, gateway: GatewayClient, settings: Settings, minute: LastMinute
88
+ ) -> None:
89
+ """Not beating yet; `leave` is set when the worker must go."""
90
+ self.server = server
91
+ self.gateway = gateway
92
+ self.fleet = settings.fleet
93
+ self.max_jobs = settings.max_jobs
94
+ self.name = worker_name_of(settings)
95
+ self.agent_name = agent_name_of(settings)
96
+ self.minute = minute
97
+ self.cordoned = False
98
+ self.leave = asyncio.Event()
99
+ # Set once LiveKit registered the worker and the gateway answered a heartbeat uncordoned.
100
+ self.ready = asyncio.Event()
101
+
102
+ def beat(self) -> HeartbeatRequest:
103
+ """What this worker holds now, and what its calls did in the last minute."""
104
+ active = len(self.server.active_jobs)
105
+ last = self.minute.of(asyncio.get_running_loop().time())
106
+ # The name only once LiveKit registered it: before, a call offered to it waits for nobody.
107
+ registered = self.server.id != UNREGISTERED
108
+ return HeartbeatRequest(
109
+ fleet=self.fleet,
110
+ worker=self.name,
111
+ agent_name=self.agent_name if registered else None,
112
+ active=active,
113
+ max_jobs=self.max_jobs,
114
+ load=self.gateways_load(active),
115
+ draining=self.server.draining,
116
+ ended=last.ended,
117
+ failed=last.failed,
118
+ errors=last.errors,
119
+ turns=last.turns,
120
+ first_audio_p95_s=last.first_audio_p95_s,
121
+ )
122
+
123
+ # The gateway's scale: calls over slots, full at 1.0 (roster._refused_at); a worker on CPU
124
+ # reports the share LiveKit reads, which is the same number on both scales.
125
+ def gateways_load(self, active: int) -> float:
126
+ """The load the gateway reads for this worker."""
127
+ if self.max_jobs is not None:
128
+ return active / self.max_jobs
129
+ load_of = self.server.load_fnc
130
+ return load_of(self.server) if isinstance(load_of, Load) else 0.0
131
+
132
+ # A gateway away is said and waited out: the worker keeps its calls meanwhile.
133
+ async def run(self) -> None:
134
+ """Beat until cordoned; then ask the worker to leave."""
135
+ while True:
136
+ try:
137
+ existing = await self.gateway.heartbeat(self.beat())
138
+ except GatewayRefused as refused:
139
+ logger.warning("heartbeat: %s", refused)
140
+ else:
141
+ if existing.cordoned:
142
+ logger.warning("cordoned by the gateway: draining, then leaving")
143
+ self.cordoned = True
144
+ self.leave.set()
145
+ return
146
+ if self.server.id != UNREGISTERED:
147
+ self.ready.set()
148
+ await asyncio.sleep(HEARTBEAT_S)
149
+
150
+
151
+ # systemd's Type=notify: `systemctl restart` returns only once this is said, so a release starts
152
+ # the next instance only after this one can take calls. Outside systemd there is nobody to tell.
153
+ async def announced_ready(beats: Heartbeats, notify: str | None) -> bool:
154
+ """Tell systemd the worker is ready once LiveKit registered it and the gateway heard it."""
155
+ await beats.ready.wait()
156
+ if not notify:
157
+ return False
158
+ # An address that starts with @ is in the abstract namespace, whose first byte is a zero.
159
+ address = "\0" + notify[1:] if notify.startswith("@") else notify
160
+ with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as systemd:
161
+ systemd.connect(address)
162
+ systemd.sendall(b"READY=1")
163
+ logger.info("registered with LiveKit and heard by the gateway: ready")
164
+ return True
165
+
166
+
167
+ # The job's process names its call's worker as the heartbeat does: the same settings, the same host.
168
+ def worker_name_of(settings: Settings) -> str:
169
+ """What the worker is called in its heartbeats and its calls: its setting, or the short host."""
170
+ return settings.worker_name or socket.gethostname().split(".")[0]
171
+
172
+
173
+ # Its own name, not the fleet's: LiveKit offers a job only to the workers registered under the
174
+ # name a dispatch carries, so the gateway reaches this process and no other (docs/scaling.md).
175
+ def agent_name_of(settings: Settings) -> str:
176
+ """The name the worker registers under with LiveKit: its fleet's, its own, its start."""
177
+ return f"{agent_name(settings.fleet, worker_name_of(settings))}.{STARTED}"
178
+
179
+
180
+ # livekit's own CPU average, read off its options' default rather than its private class.
181
+ def livekits_measure() -> Callable[[AgentServer], float]:
182
+ """The load function livekit runs when given none: the machine's CPU over a few seconds."""
183
+ (declared,) = (option for option in fields(ServerOptions) if option.name == "load_fnc")
184
+ measure = declared.default
185
+ if not callable(measure):
186
+ raise DeclarationRefused(NO_MEASURE)
187
+
188
+ def measured(server: AgentServer) -> float:
189
+ load = measure(server)
190
+ if not isinstance(load, int | float):
191
+ raise DeclarationRefused(NO_MEASURE)
192
+ return float(load)
193
+
194
+ return measured
@@ -0,0 +1,157 @@
1
+ """A worker's last minute: what its calls' job processes tell it, and what its heartbeat carries."""
2
+
3
+ import asyncio
4
+ import math
5
+ import socket
6
+ import tempfile
7
+ from collections import deque
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, override
12
+
13
+ from pydantic import BaseModel, ConfigDict, ValidationError
14
+
15
+ from pinecall.process.settings import Settings
16
+ from pinecall.wire.rest.calls import BatchedEntry
17
+
18
+ # What the heartbeat carries is the last minute's, as the roster judges it.
19
+ WINDOW_S = 60.0
20
+
21
+
22
+ # A busy minute is thousands of turns: past this many, the oldest are forgotten early.
23
+ MOST_KEPT = 10_000
24
+
25
+
26
+ # A p95 is read off this many turns at least; fewer, and the heartbeat carries none.
27
+ TURNS_FOR_A_P95 = 5
28
+
29
+
30
+ # How a call that failed ended; one datagram per thing told, and a datagram lost is uncounted.
31
+ AN_ERROR = "error"
32
+
33
+
34
+ class JobReport(BaseModel):
35
+ """One thing a job tells its worker: a turn's first audio, an error, or how its call ended."""
36
+
37
+ model_config = ConfigDict(frozen=True, extra="ignore")
38
+
39
+ first_audio_s: float | None = None
40
+ error: bool = False
41
+ ended: str | None = None
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class Minute:
46
+ """A worker's calls over the last minute: ended, failed, error entries, and first audio."""
47
+
48
+ ended: int
49
+ # Of the calls that ended, those that ended in an error.
50
+ failed: int
51
+ errors: int
52
+ turns: int
53
+ first_audio_p95_s: float | None
54
+
55
+
56
+ class LastMinute:
57
+ """What the worker's job processes told it, kept for a minute, in the worker's main process."""
58
+
59
+ def __init__(self) -> None:
60
+ """Nothing heard yet."""
61
+ self.first_audio: deque[tuple[float, float]] = deque(maxlen=MOST_KEPT)
62
+ self.errors: deque[float] = deque(maxlen=MOST_KEPT)
63
+ self.ended: deque[tuple[float, bool]] = deque(maxlen=MOST_KEPT)
64
+
65
+ def heard(self, message: bytes, now: float) -> None:
66
+ """One datagram from a job; one that does not read is dropped."""
67
+ try:
68
+ report = JobReport.model_validate_json(message)
69
+ except ValidationError:
70
+ return
71
+ if report.first_audio_s is not None:
72
+ self.first_audio.append((now, report.first_audio_s))
73
+ if report.error:
74
+ self.errors.append(now)
75
+ if report.ended is not None:
76
+ self.ended.append((now, report.ended == AN_ERROR))
77
+
78
+ def of(self, now: float) -> Minute:
79
+ """The minute up to now."""
80
+ since = now - WINDOW_S
81
+ while self.first_audio and self.first_audio[0][0] < since:
82
+ self.first_audio.popleft()
83
+ while self.errors and self.errors[0] < since:
84
+ self.errors.popleft()
85
+ while self.ended and self.ended[0][0] < since:
86
+ self.ended.popleft()
87
+ waits = sorted(value for _, value in self.first_audio)
88
+ return Minute(
89
+ ended=len(self.ended),
90
+ failed=sum(1 for _, failed in self.ended if failed),
91
+ errors=len(self.errors),
92
+ turns=len(waits),
93
+ first_audio_p95_s=_p95(waits) if len(waits) >= TURNS_FOR_A_P95 else None,
94
+ )
95
+
96
+
97
+ class _Heard(asyncio.DatagramProtocol):
98
+ def __init__(self, minute: LastMinute) -> None:
99
+ self.minute = minute
100
+
101
+ @override
102
+ def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None:
103
+ del addr
104
+ self.minute.heard(data, asyncio.get_running_loop().time())
105
+
106
+
107
+ # Private to the unit on the box (PrivateTmp=yes); the health port tells two workers of a machine
108
+ # apart, as it must already, since each binds its own.
109
+ def measures_path(settings: Settings) -> Path:
110
+ """Where a worker hears its jobs: a datagram socket its main process binds."""
111
+ name = f"pinecall-{settings.fleet}-{settings.worker_http_port}.measures"
112
+ return Path(tempfile.gettempdir()) / name
113
+
114
+
115
+ # Bound before livekit starts its processes; a leftover of a worker that died is taken over.
116
+ async def listening(path: Path, minute: LastMinute) -> asyncio.DatagramTransport:
117
+ """Bind the socket the worker's jobs tell it through."""
118
+ await asyncio.to_thread(path.unlink, missing_ok=True)
119
+ transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(
120
+ lambda: _Heard(minute), local_addr=str(path), family=socket.AF_UNIX
121
+ )
122
+ return transport
123
+
124
+
125
+ # Called on every batch a job sends: most carry none of the three, and send nothing.
126
+ def reported(path: Path, entries: Sequence[BatchedEntry]) -> None:
127
+ """Tell the worker what a batch of its call's entries says: first audio, errors, an end."""
128
+ messages = [message for entry in entries if (message := _message_of(entry)) is not None]
129
+ if not messages:
130
+ return
131
+ with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sender:
132
+ sender.settimeout(0)
133
+ for message in messages:
134
+ try:
135
+ sender.sendto(message, str(path))
136
+ except OSError:
137
+ # A worker not listening (a test, or its main process gone) loses the measure only.
138
+ return
139
+
140
+
141
+ # Nearest rank, of values already sorted.
142
+ def _p95(values: Sequence[float]) -> float:
143
+ return values[max(math.ceil(0.95 * len(values)) - 1, 0)]
144
+
145
+
146
+ def _message_of(entry: BatchedEntry) -> bytes | None:
147
+ if entry.type == "turn.agent":
148
+ measured = entry.data.get("metrics")
149
+ waited = measured.get("e2e_latency") if isinstance(measured, dict) else None
150
+ if isinstance(waited, int | float):
151
+ return JobReport(first_audio_s=waited).model_dump_json().encode()
152
+ if entry.type == AN_ERROR:
153
+ return JobReport(error=True).model_dump_json().encode()
154
+ if entry.type == "call.ended":
155
+ reason = entry.data.get("reason")
156
+ return JobReport(ended=reason if isinstance(reason, str) else "").model_dump_json().encode()
157
+ return None
@@ -0,0 +1,232 @@
1
+ """The roster of workers, one per fleet: who is up, what each holds, and whether a fleet is full."""
2
+
3
+ import time
4
+ from collections.abc import Iterable, Sequence
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ from pydantic import TypeAdapter
9
+
10
+ from pinecall.process.shared import Assigned, Shared, newest
11
+ from pinecall.process.signal import LocalSignal, Signal
12
+ from pinecall.wire.rest.fleet import FleetTotals, HeartbeatRequest, HeartbeatResponse, WorkerStatus
13
+
14
+ type WorkerState = Literal["gone", "cordoned", "draining", "accepting", "failing", "full"]
15
+
16
+ # LiveKit's line, the same on both of its sides: livekit-server's agent.DefaultTargetLoad
17
+ # (pkg/agent/config.go; a job is offered to a worker whose reported load is under it) and
18
+ # livekit-agents' load_threshold (the worker declines at or over it). A worker gated on CPU
19
+ # reports its share of the machine, which is that scale. A worker that counts its calls reports
20
+ # 0.7 times its calls over its slots to LiveKit (fleet/heartbeat.Load), so LiveKit's full is its
21
+ # last slot, and calls over slots to the gateway, full at 1.0: on 2026-10-02 it reported calls over
22
+ # slots to both, and LiveKit stopped at 0.7 of them while the gateway still counted seats free.
23
+ REFUSED_AT = 0.7
24
+ EVERY_SLOT = 1.0
25
+ OVERFLOW = "overflow"
26
+ HEARTBEAT_S = 5.0
27
+ # A worker silent this long is no longer capacity.
28
+ STALE_AFTER_S = 30.0
29
+ # A silent worker stays listed, as gone, this long.
30
+ FORGOTTEN_AFTER_S = 3600.0
31
+ # A worker up but bad: at least half the calls that ended in its last minute ended in an error,
32
+ # judged on four calls at least, or its callers waited over five seconds for first audio at the
33
+ # p95, over twenty turns at least. Set so they change nothing a healthy worker does.
34
+ FAILED_SHARE = 0.5
35
+ ENDED_TO_JUDGE = 4
36
+ SLOW_FIRST_AUDIO_S = 5.0
37
+ TURNS_TO_JUDGE = 20
38
+
39
+ # Every gateway says the heartbeats it heard and the cordons it set, once a heartbeat; each answers
40
+ # a heartbeat's `full` from totals at most this old, not by walking the fleet every time.
41
+ ROSTER_CHANNEL = "roster"
42
+ TOTALS_KEPT_S = 1.0
43
+ CORDONED = "cordoned"
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Heard:
48
+ """One gateway's share: the workers that beat on it lately, and the cordons it set."""
49
+
50
+ seats: tuple[WorkerStatus, ...] = ()
51
+ cordons: tuple[Assigned, ...] = ()
52
+
53
+
54
+ # In memory, on every gateway: each keeps the heartbeats that reached it and hears the others', so
55
+ # a worker beating on any gateway is counted on all of them. After a restart the next round of
56
+ # heartbeats, five seconds, rebuilds it; a cordon set on one gateway stands on all, the newest
57
+ # setting of a worker's cordon winning.
58
+ class Roster:
59
+ """The workers of every fleet, by fleet and name, as their heartbeats say on any gateway."""
60
+
61
+ def __init__(self, signal: Signal | None = None) -> None:
62
+ """Nobody heard from yet."""
63
+ # Every gateway's workers, the newest heartbeat of each: what every read answers from.
64
+ self.seats: dict[tuple[str, str], WorkerStatus] = {}
65
+ self.heard_here: dict[tuple[str, str], WorkerStatus] = {}
66
+ self.cordons: dict[tuple[str, str], Assigned] = {}
67
+ self.cordoned: dict[tuple[str, str], str] = {}
68
+ self.shared = Shared(signal or LocalSignal(), ROSTER_CHANNEL, _HEARD, Heard(), self._merged)
69
+ self.shared.every_s = HEARTBEAT_S
70
+ self.shared.gathered = self._gathered
71
+ self._totals: dict[str, tuple[float, FleetTotals]] = {}
72
+
73
+ async def start(self) -> None:
74
+ """Hear the other gateways' workers, and say this one's every heartbeat."""
75
+ await self.shared.start()
76
+
77
+ async def close(self) -> None:
78
+ """Stop hearing and saying."""
79
+ await self.shared.close()
80
+
81
+ def report(self, beat: HeartbeatRequest, now: float) -> HeartbeatResponse:
82
+ """Keep the heartbeat and answer the worker's standing; a cordon outlives heartbeats."""
83
+ cordoned = (beat.fleet, beat.worker) in self.cordoned
84
+ seat = WorkerStatus(
85
+ fleet=beat.fleet,
86
+ worker=beat.worker,
87
+ agent_name=beat.agent_name,
88
+ active=beat.active,
89
+ max_jobs=beat.max_jobs,
90
+ load=beat.load,
91
+ draining=beat.draining,
92
+ cordoned=cordoned,
93
+ seen_at=now,
94
+ ended=beat.ended,
95
+ failed=beat.failed,
96
+ errors=beat.errors,
97
+ turns=beat.turns,
98
+ first_audio_p95_s=beat.first_audio_p95_s,
99
+ )
100
+ self.heard_here[(beat.fleet, beat.worker)] = seat
101
+ self.seats[(beat.fleet, beat.worker)] = seat
102
+ return HeartbeatResponse(cordoned=cordoned, full=self._kept_totals(beat.fleet, now).full)
103
+
104
+ def cordon(self, fleet: str, worker: str, *, on: bool = True) -> bool:
105
+ """Cordon a worker, or take the cordon back; False for a name nobody has."""
106
+ if (fleet, worker) not in self.seats:
107
+ return False
108
+ holder = CORDONED if on else None
109
+ self.cordons[(fleet, worker)] = Assigned(fleet, worker, holder, time.time())
110
+ self._merged()
111
+ self.shared.put(self._gathered())
112
+ return True
113
+
114
+ def of(self, fleet: str, now: float) -> list[WorkerStatus]:
115
+ """A fleet's workers by last heartbeat, forgetting the ones silent for an hour."""
116
+ for key, seat in list(self.seats.items()):
117
+ if now - seat.seen_at > FORGOTTEN_AFTER_S:
118
+ del self.seats[key]
119
+ return sorted(
120
+ (seat for seat in self.seats.values() if seat.fleet == fleet),
121
+ key=lambda seat: seat.seen_at,
122
+ )
123
+
124
+ def _kept_totals(self, fleet: str, now: float) -> FleetTotals:
125
+ kept = self._totals.get(fleet)
126
+ if kept is not None and 0 <= now - kept[0] < TOTALS_KEPT_S:
127
+ return kept[1]
128
+ totals = self.totals(fleet, now)
129
+ self._totals[fleet] = (now, totals)
130
+ return totals
131
+
132
+ def _gathered(self) -> Heard:
133
+ return Heard(seats=tuple(self.heard_here.values()), cordons=tuple(self.cordons.values()))
134
+
135
+ # Each worker's newest heartbeat, wherever it beat; each cordon's newest setting.
136
+ def _merged(self) -> None:
137
+ theirs = [heard.share for heard in self.shared.theirs.values()]
138
+ settings = (setting for share in theirs for setting in share.cordons)
139
+ self.cordoned = newest(self.cordons, settings)
140
+ merged = dict(self.heard_here)
141
+ for share in theirs:
142
+ for seat in share.seats:
143
+ known = merged.get((seat.fleet, seat.worker))
144
+ if known is None or known.seen_at < seat.seen_at:
145
+ merged[(seat.fleet, seat.worker)] = seat
146
+ self.seats = {
147
+ key: seat.model_copy(update={"cordoned": key in self.cordoned})
148
+ for key, seat in merged.items()
149
+ }
150
+
151
+ # A fleet nobody heard from is not full: a gateway with no worker yet refuses nobody.
152
+ def totals(self, fleet: str, now: float) -> FleetTotals:
153
+ """A fleet's workers heard from lately, summed."""
154
+ up = [
155
+ seat for seat in self.seats.values() if seat.fleet == fleet and heard_lately(seat, now)
156
+ ]
157
+ accepting = len(accepting_of(up, now))
158
+ return FleetTotals(
159
+ fleet=fleet,
160
+ workers=len(up),
161
+ active=sum(seat.active for seat in up),
162
+ seats=sum(seat.max_jobs or 0 for seat in up),
163
+ free=sum(max(0, (seat.max_jobs or 0) - seat.active) for seat in up if seat.max_jobs),
164
+ accepting=accepting,
165
+ full=bool(up) and accepting == 0,
166
+ )
167
+
168
+
169
+ _HEARD: TypeAdapter[Heard] = TypeAdapter(Heard)
170
+
171
+
172
+ def heard_lately(seat: WorkerStatus, now: float) -> bool:
173
+ """Whether the worker's last heartbeat is recent enough to count."""
174
+ return now - seat.seen_at <= STALE_AFTER_S
175
+
176
+
177
+ def failing(seat: WorkerStatus) -> bool:
178
+ """Whether the worker's last minute is past the line: its calls fail, or its callers wait."""
179
+ ended, failed = seat.ended or 0, seat.failed or 0
180
+ if ended >= ENDED_TO_JUDGE and failed / ended >= FAILED_SHARE:
181
+ return True
182
+ slow = seat.first_audio_p95_s
183
+ return (seat.turns or 0) >= TURNS_TO_JUDGE and slow is not None and slow > SLOW_FIRST_AUDIO_S
184
+
185
+
186
+ # A failing worker is not counted while another of its fleet accepts and is not failing: the line
187
+ # never empties a fleet, so a box of one worker per world keeps counting its one.
188
+ def accepting_of(seats: Iterable[WorkerStatus], now: float) -> list[WorkerStatus]:
189
+ """The fleet's workers counted as accepting: up and under the line, and not failing."""
190
+ accepting = [seat for seat in seats if _accepting_now(seat, now)]
191
+ sound = [seat for seat in accepting if not failing(seat)]
192
+ return sound or accepting
193
+
194
+
195
+ def worker_state(seat: WorkerStatus, fleet: Sequence[WorkerStatus], now: float) -> WorkerState:
196
+ """How the roster counts the worker among its fleet's: gone, cordoned, draining, and so on."""
197
+ if not heard_lately(seat, now):
198
+ return "gone"
199
+ if seat.cordoned:
200
+ return "cordoned"
201
+ if seat.draining:
202
+ return "draining"
203
+ if seat in accepting_of(fleet, now):
204
+ return "accepting"
205
+ return "failing" if _accepting_now(seat, now) else "full"
206
+
207
+
208
+ # A worker registers with LiveKit under <fleet>/<worker>, the fleet's overflow under
209
+ # <fleet>/overflow: a dispatch reaches exactly the workers registered under its name.
210
+ def agent_name(fleet: str, worker: str) -> str:
211
+ """The name a worker of a fleet is registered under with LiveKit."""
212
+ return f"{fleet}/{worker}"
213
+
214
+
215
+ def overflow_name(fleet: str) -> str:
216
+ """The name the fleet's overflow is registered under with LiveKit."""
217
+ return agent_name(fleet, OVERFLOW)
218
+
219
+
220
+ def _refused_at(max_jobs: int | None) -> float:
221
+ """The load a heartbeat says full at: every slot of a worker that counts, 0.7 of a CPU."""
222
+ return REFUSED_AT if max_jobs is None else EVERY_SLOT
223
+
224
+
225
+ def _accepting_now(seat: WorkerStatus, now: float) -> bool:
226
+ """Whether livekit would dispatch to it: up, not cordoned, not draining, under its line."""
227
+ return (
228
+ heard_lately(seat, now)
229
+ and not seat.cordoned
230
+ and not seat.draining
231
+ and seat.load < _refused_at(seat.max_jobs)
232
+ )
@@ -0,0 +1,44 @@
1
+ """The fleet each world's calls are dispatched to, as the operator named them."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from pinecall.domain.names import PRODUCTION, SANDBOX, Env
6
+ from pinecall.postgres.pool import Pool
7
+ from pinecall.process import box_settings
8
+ from pinecall.process.settings import A_FLEET_NAME
9
+
10
+ FLEETS = "fleets"
11
+
12
+
13
+ class Fleets(BaseModel):
14
+ """The fleet of workers each world's calls are dispatched to."""
15
+
16
+ model_config = ConfigDict(frozen=True, extra="forbid")
17
+
18
+ production: str = Field(default="pinecall", pattern=A_FLEET_NAME)
19
+ sandbox: str = Field(default="pinecall-sandbox", pattern=A_FLEET_NAME)
20
+
21
+
22
+ async def fleets(pool: Pool) -> Fleets:
23
+ """The fleet each world's calls go to."""
24
+ async with pool.connection() as connection:
25
+ value = await box_settings.read(connection, FLEETS)
26
+ return Fleets() if value is None else Fleets.model_validate(value)
27
+
28
+
29
+ async def set_fleets(pool: Pool, named: Fleets) -> None:
30
+ """Write the fleet of each world, whole."""
31
+ async with pool.connection() as connection:
32
+ await box_settings.write(connection, FLEETS, named.model_dump(mode="json"))
33
+
34
+
35
+ def world_of(named: Fleets, fleet: str) -> Env | None:
36
+ """The world whose calls go to the fleet; None when no world's do."""
37
+ if fleet == named.production:
38
+ return PRODUCTION
39
+ return SANDBOX if fleet == named.sandbox else None
40
+
41
+
42
+ def fleet_of(named: Fleets, env: Env) -> str:
43
+ """The fleet a call of the world is dispatched to."""
44
+ return named.production if env == PRODUCTION else named.sandbox
File without changes
@@ -0,0 +1,53 @@
1
+ """What a call of an agent is set up with: its config tuned by the scope, the keys, a refusal."""
2
+
3
+ from cryptography.fernet import MultiFernet
4
+
5
+ from pinecall.domain.agent import AgentConfig, Versions
6
+ from pinecall.domain.errors import QuotaExhausted
7
+ from pinecall.domain.scope import Scope
8
+ from pinecall.log.logs import Logs
9
+ from pinecall.postgres.pool import Pool
10
+ from pinecall.providers.catalog import Providers
11
+ from pinecall.providers.credentials import Keyring
12
+ from pinecall.providers.declared import apply_tuning
13
+ from pinecall.tenancy import admission, scopes, vault
14
+ from pinecall.tenancy.scopes import Picked
15
+ from pinecall.wire.events import (
16
+ CreditsExhausted,
17
+ )
18
+
19
+
20
+ # A call's own id picks its version where the scope stands on a canary; no call is the rest.
21
+ async def tuned(
22
+ pool: Pool,
23
+ declared: AgentConfig,
24
+ scope: Scope,
25
+ configured: Providers,
26
+ picked: Picked | None = None,
27
+ ) -> tuple[AgentConfig, Versions]:
28
+ """The declaration under the scope's settings, and the versions it was built from."""
29
+ existing = await scopes.current(pool, scope, declared.slug, picked)
30
+ config = apply_tuning(declared, existing.tuning, existing.lexicon, defaults=configured.defaults)
31
+ return config, existing.versions
32
+
33
+
34
+ # Read per call, so a rotated key runs from the next call on.
35
+ async def keys_of(pool: Pool, sealed: MultiFernet, scope: Scope) -> Keyring:
36
+ """What the org's calls in the world may run on."""
37
+ quotas = await admission.quotas_of(pool, scope.org, scope.env)
38
+ return Keyring(
39
+ own=await vault.credentials_of(pool, sealed, scope.org),
40
+ box=await vault.box_credentials(pool, sealed),
41
+ lends=quotas.lends,
42
+ )
43
+
44
+
45
+ # The refusal is written on the agent's log as the numbers the quota ran out at.
46
+ async def exhausted(logs: Logs, org: str, agent: str, refused: QuotaExhausted) -> None:
47
+ """credits.exhausted on the agent's log, for a refusal that names its quota."""
48
+ if refused.quota is None:
49
+ return
50
+ text = CreditsExhausted.model_validate(
51
+ {"org": org, "quota": refused.quota, "used": refused.used, "limit": refused.limit}
52
+ )
53
+ await logs.agent(agent).append("credits.exhausted", text.written())