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,240 @@
1
+ """The providers doors: the catalogue, the org's own vendor keys, a vendor's voices, a sample."""
2
+
3
+ import dataclasses
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Query, Response
7
+
8
+ from pinecall.domain.agent import Voice
9
+ from pinecall.domain.errors import DeclarationRefused, NotFound, TooManyRequests
10
+ from pinecall.domain.names import Credentials
11
+ from pinecall.gateway._call_setup import keys_of
12
+ from pinecall.gateway._deps import GatewayDep, PipelineKey, ProvidersKey, ScopeDep
13
+ from pinecall.providers import build, catalog, credentials, voices
14
+ from pinecall.providers.build import Modality, primary
15
+ from pinecall.providers.catalog import Providers
16
+ from pinecall.providers.credentials import Keyring
17
+ from pinecall.providers.declared import model_of
18
+ from pinecall.tenancy import vault
19
+ from pinecall.wire.rest.providers import (
20
+ Availability,
21
+ Catalogue,
22
+ ListedVoice,
23
+ ProviderKeyRequest,
24
+ ProviderRow,
25
+ Standing,
26
+ VendorsResponse,
27
+ VoiceSampleRequest,
28
+ VoicesListed,
29
+ )
30
+
31
+ router = APIRouter()
32
+
33
+
34
+ WAV = "audio/wav"
35
+
36
+
37
+ # Samples cost vendor time and write no usage row, so a key gets this many a minute.
38
+ SAMPLES_A_MINUTE = 30
39
+
40
+
41
+ # Long enough for a greeting, short enough to keep the vendor's cost to seconds.
42
+ LONGEST_SAMPLE = 400
43
+
44
+
45
+ TOO_LONG = "a sample says a sentence: {length} characters, and the ceiling is {ceiling}"
46
+
47
+
48
+ TOO_MANY = "this key asked for its {count} samples this minute: the ceiling is {count} a minute"
49
+
50
+
51
+ NO_CREDENTIALS = "a key, or the credentials object the vendor's plugin takes, and not both"
52
+
53
+
54
+ NO_SUCH_KEY = "this org brought no key for {vendor}"
55
+
56
+
57
+ # What a voice says when the picker names no line and the providers row has none in the language.
58
+ A_LINE = "Hello, this is the voice you are listening to."
59
+
60
+
61
+ # Availabilities a call can run on.
62
+ READY = frozenset({"yours", "offered"})
63
+
64
+
65
+ # v1's word for each, which the console draws: a vendor nobody keyed for this org has "no key".
66
+ STANDING: dict[Availability, Standing] = {
67
+ "yours": "ready",
68
+ "offered": "ready",
69
+ "bring your own": "no key",
70
+ "broken": "no plugin",
71
+ }
72
+
73
+
74
+ # The same for every key of an org: what is installed, and whose key would run each vendor.
75
+ @router.get("/v1/providers")
76
+ async def catalogue(_key: ProvidersKey, scope: ScopeDep, gateway: GatewayDep) -> Catalogue:
77
+ """Every vendor this build runs and how this org may run it, the defaults, the models named."""
78
+ pool = gateway.connections.pool
79
+ configured = await catalog.providers(pool)
80
+ return catalogue_of(configured, await keys_of(pool, gateway.connections.vault, scope))
81
+
82
+
83
+ # Vendor names only, never a key or a prefix of one.
84
+ @router.get("/v1/provider-keys")
85
+ async def own_vendors(key: ProvidersKey, gateway: GatewayDep) -> VendorsResponse:
86
+ """The vendors the org brought its own credentials for."""
87
+ return VendorsResponse(vendors=await vault.vendors_of(gateway.connections.pool, key.org))
88
+
89
+
90
+ @router.put("/v1/provider-keys/{vendor}", status_code=204)
91
+ async def bring_key(
92
+ vendor: str, body: ProviderKeyRequest, key: ProvidersKey, gateway: GatewayDep
93
+ ) -> None:
94
+ """Keep the org's own credentials for a vendor; its calls run on them from the next one."""
95
+ named = installed_vendor(vendor)
96
+ credentials = credentials_of(body)
97
+ build.check_credentials(named, credentials)
98
+ connections = gateway.connections
99
+ await vault.put_credentials(connections.pool, connections.vault, key.org, named, credentials)
100
+
101
+
102
+ @router.delete("/v1/provider-keys/{vendor}", status_code=204)
103
+ async def take_key_back(vendor: str, key: ProvidersKey, gateway: GatewayDep) -> None:
104
+ """Forget the org's credentials for a vendor; its calls run on the box's from the next one."""
105
+ named = installed_vendor(vendor)
106
+ if not await vault.drop_credentials(gateway.connections.pool, key.org, named):
107
+ raise NotFound(NO_SUCH_KEY.format(vendor=named))
108
+
109
+
110
+ # Built as a call builds the stage: the operator's options, on the key a call would use.
111
+ @router.get("/v1/voices")
112
+ async def list_voices(
113
+ _key: PipelineKey,
114
+ scope: ScopeDep,
115
+ gateway: GatewayDep,
116
+ tts: Annotated[str, Query(min_length=1)],
117
+ language: Annotated[str | None, Query()] = None,
118
+ ) -> VoicesListed:
119
+ """The voices the providers row lists for a vendor, else its plugin's; neither is a 404."""
120
+ pool = gateway.connections.pool
121
+ configured = await catalog.providers(pool)
122
+ named = installed_vendor(tts)
123
+ spoken = primary(language)
124
+ listed = voices.kept(configured, named, spoken)
125
+ if not listed and not _lists_voices(named, ("tts",)):
126
+ raise NotFound(voices.NOT_LISTED.format(vendor=named))
127
+ if not listed:
128
+ keyring = await keys_of(pool, gateway.connections.vault, scope)
129
+ stage = credentials.stage("tts", Voice(provider=named), configured, keyring)
130
+ listed = await voices.voices(dataclasses.replace(stage, language=spoken))
131
+ return VoicesListed(
132
+ tts=tts,
133
+ language=language,
134
+ voices=[
135
+ voice
136
+ for item in listed
137
+ if (voice := _listed_voice(item)) is not None
138
+ and (spoken is None or not voice.language or voice.language.startswith(spoken))
139
+ ],
140
+ )
141
+
142
+
143
+ # The three words are read exactly as the settings door reads them, so what plays is what saves.
144
+ # The answer is the WAV itself, its latencies in Server-Timing, which a browser shows.
145
+ @router.post("/v1/voices/sample")
146
+ async def voice_sample(
147
+ body: VoiceSampleRequest, key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
148
+ ) -> Response:
149
+ """The line said by that vendor's voice, over the path a call speaks on."""
150
+ if not await gateway.samples.allowed(key.bearer.key.key_id):
151
+ raise TooManyRequests(TOO_MANY.format(count=gateway.samples.tries))
152
+ pool = gateway.connections.pool
153
+ configured = await catalog.providers(pool)
154
+ language = primary(body.language)
155
+ text = body.text or configured.lines.get(language or "", A_LINE)
156
+ if len(text) > LONGEST_SAMPLE:
157
+ raise DeclarationRefused(TOO_LONG.format(length=len(text), ceiling=LONGEST_SAMPLE))
158
+ named = model_of(body.tts, "tts", in_use=configured.defaults["tts"].vendor)
159
+ voice = Voice(
160
+ provider=configured.defaults["tts"].vendor if named is None else named.provider,
161
+ model=body.model or (None if named is None else named.model or None),
162
+ voice_id=body.voice,
163
+ )
164
+ keyring = await keys_of(pool, gateway.connections.vault, scope)
165
+ stage = credentials.stage("tts", voice, configured, keyring)
166
+ heard = await voices.sample(
167
+ dataclasses.replace(stage, voice=voice.voice_id, language=language), text
168
+ )
169
+ timing = f"first-audio;dur={heard.first_audio_ms}, total;dur={heard.total_ms}"
170
+ return Response(content=heard.wav, media_type=WAV, headers={"Server-Timing": timing})
171
+
172
+
173
+ def catalogue_of(configured: Providers, keyring: Keyring) -> Catalogue:
174
+ """Every installed vendor as this org may run it, with the row's defaults and models."""
175
+ return Catalogue(
176
+ providers=[
177
+ ProviderRow(
178
+ name=vendor.name,
179
+ does=list(vendor.does),
180
+ aliases=[],
181
+ note=vendor.broken or "",
182
+ standing=STANDING[vendor.availability],
183
+ ready=vendor.availability in READY,
184
+ env=None,
185
+ extra=vendor.name,
186
+ voices_listed=_lists_voices(vendor.name, vendor.does)
187
+ or voices.lists_any(configured, vendor.name),
188
+ availability=vendor.availability,
189
+ broken=vendor.broken,
190
+ )
191
+ for vendor in credentials.readiness(build.installed(), keyring)
192
+ ],
193
+ defaults={modality: stage.vendor for modality, stage in configured.defaults.items()},
194
+ voices=[],
195
+ models={named: [model] for named, model in configured.models.items()},
196
+ )
197
+
198
+
199
+ def installed_vendor(vendor: str) -> str:
200
+ """The vendor's name as installed; refused naming the ones this build runs."""
201
+ named = vendor.strip().lower()
202
+ if named not in build.installed():
203
+ known = ", ".join(sorted(build.installed()))
204
+ raise DeclarationRefused(f"no vendor named {vendor!r}; this build runs: {known}")
205
+ return named
206
+
207
+
208
+ def credentials_of(body: ProviderKeyRequest) -> Credentials:
209
+ """One key, or the object the plugin takes, out of the body; refused when neither or both."""
210
+ if (body.key is None) == (body.credentials is None):
211
+ raise DeclarationRefused(NO_CREDENTIALS)
212
+ if body.key is not None:
213
+ if not body.key.strip():
214
+ raise DeclarationRefused(NO_CREDENTIALS)
215
+ return body.key.strip()
216
+ return body.credentials or {}
217
+
218
+
219
+ # A plugin lists its voices when its TTS class has a `list_voices`; nothing is built to ask.
220
+ def _lists_voices(vendor: str, does: tuple[Modality, ...]) -> bool:
221
+ if "tts" not in does:
222
+ return False
223
+ try:
224
+ speech = getattr(build.plugin(vendor), "TTS", None)
225
+ except DeclarationRefused:
226
+ return False
227
+ return callable(getattr(speech, "list_voices", None))
228
+
229
+
230
+ def _listed_voice(item: voices.ListedVoice) -> ListedVoice | None:
231
+ detail = item.detail
232
+ return ListedVoice(
233
+ id=item.id,
234
+ name=item.name,
235
+ language=str(detail.get("language") or ""),
236
+ description=str(detail.get("description") or ""),
237
+ gender=str(detail.get("gender") or ""),
238
+ country=str(detail.get("country") or ""),
239
+ accent=str(detail.get("accent") or ""),
240
+ )
@@ -0,0 +1,89 @@
1
+ """A call's recording as a player reads it: plain, or sealed and opened a range at a time."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Header
8
+ from fastapi.responses import FileResponse, StreamingResponse
9
+
10
+ from pinecall.domain.errors import NotFound
11
+ from pinecall.gateway import _deps
12
+ from pinecall.gateway._deps import CallReaderDep, GatewayDep
13
+ from pinecall.gateway._gateway import Gateway
14
+ from pinecall.log import queries
15
+ from pinecall.process.recordings import (
16
+ SEALED_FILE,
17
+ Fetched,
18
+ kept_on_disk,
19
+ recordings_of,
20
+ served_sealed,
21
+ )
22
+ from pinecall.process.sealed_audio import on_disk
23
+ from pinecall.tenancy import recording_keys
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ NOT_RECORDED = "call {call} kept no recording"
29
+
30
+
31
+ NOT_HERE = "the recording of {call} is at {path} on the box that took the call, not on this one"
32
+
33
+
34
+ # From the bucket once the worker moved it there, else the file the recorder wrote on this disk;
35
+ # a sealed one is opened by the call's own key, the range asked for and no more.
36
+ @router.get("/v1/calls/{call}/recording", response_model=None)
37
+ async def recording(
38
+ call: str,
39
+ reading: CallReaderDep,
40
+ gateway: GatewayDep,
41
+ byte_range: Annotated[str | None, Header(alias="range")] = None,
42
+ ) -> FileResponse | StreamingResponse:
43
+ """The call's audio, with byte ranges so a player can seek."""
44
+ await _deps.check_readable(gateway, reading, call)
45
+ await _deps.record_read(gateway, reading, call, "recording")
46
+ state = await gateway.logs.reading(call).snapshot()
47
+ summary = next(
48
+ (
49
+ item
50
+ for item in reversed(await gateway.logs.store.whole(call))
51
+ if item.type == "call.summary"
52
+ ),
53
+ None,
54
+ )
55
+ pointer = None if summary is None else summary.data.get("recording")
56
+ if state.seq == 0 or not isinstance(pointer, str) or not pointer:
57
+ raise NotFound(NOT_RECORDED.format(call=call))
58
+ root = Path(gateway.connections.settings.recordings_root)
59
+ path = await asyncio.to_thread(kept_on_disk, root, call, pointer)
60
+ if path is None:
61
+ raise NotFound(NOT_RECORDED.format(call=call))
62
+ kept = await queries.scope_of_call(gateway.connections.pool, call)
63
+ if kept is not None and kept.scope is not None:
64
+ fetched = await _fetched(gateway, kept.scope.org, call, path, byte_range)
65
+ if fetched is not None:
66
+ headers = {
67
+ **fetched.headers,
68
+ "content-disposition": f'attachment; filename="{call}.ogg"',
69
+ }
70
+ return StreamingResponse(
71
+ fetched.body, status_code=fetched.status, headers=headers, media_type="audio/ogg"
72
+ )
73
+ if path.name == SEALED_FILE or not await asyncio.to_thread(path.is_file):
74
+ raise NotFound(NOT_HERE.format(call=call, path=pointer))
75
+ return FileResponse(path, media_type="audio/ogg", filename=f"{call}.ogg")
76
+
77
+
78
+ # A sealed recording is in the bucket, or on this disk where its upload failed; a plain one is
79
+ # the bucket's to answer, and a file on this disk is served as a file.
80
+ async def _fetched(
81
+ gateway: Gateway, org: str, call: str, path: Path, byte_range: str | None
82
+ ) -> Fetched | None:
83
+ connections = gateway.connections
84
+ stored = recordings_of(connections.settings, connections.http)
85
+ if path.name != SEALED_FILE:
86
+ return await stored.fetch(org, call, byte_range)
87
+ key = await recording_keys.key_of(connections.pool, connections.vault, call)
88
+ kept = await stored.sealed(org, call) or await on_disk(call, path)
89
+ return None if key is None or kept is None else served_sealed(kept, key, byte_range)
@@ -0,0 +1,169 @@
1
+ """The console's asks relayed to a running app: chat, knowledge, memory, the view, evals."""
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from typing import Annotated, get_args
6
+ from uuid import uuid4
7
+
8
+ from fastapi import APIRouter, Depends, Query
9
+
10
+ from pinecall.domain.errors import (
11
+ AppRefused,
12
+ Conflict,
13
+ NotFound,
14
+ )
15
+ from pinecall.domain.names import JsonObject
16
+ from pinecall.domain.scope import Scope
17
+ from pinecall.gateway import _deps
18
+ from pinecall.gateway._deps import Acting, CallsKey, GatewayDep
19
+ from pinecall.gateway._gateway import Gateway
20
+ from pinecall.gateway._sockets import (
21
+ NO_AGENT,
22
+ NO_DEV_ANSWERER,
23
+ NO_UNCLAIMED,
24
+ NOT_THAT_APP,
25
+ Registration,
26
+ )
27
+ from pinecall.tenancy import keys
28
+ from pinecall.wire.events import DevRequest
29
+ from pinecall.wire.parts import DevVerb
30
+
31
+ router = APIRouter()
32
+
33
+
34
+ NO_SUCH_VERB = "no dev verb {verb} in {family}: the verbs are {verbs}"
35
+
36
+
37
+ NO_ANSWER = "the app holding agent {slug} did not answer {verb} within {seconds:.0f}s"
38
+
39
+
40
+ APP_LEFT = "the app holding agent {slug} disconnected before it answered {verb}"
41
+
42
+
43
+ # Longer than any verb takes: a suite waits 20 s on the app's side.
44
+ ANSWERED_WITHIN_S = 120.0
45
+
46
+
47
+ NAMED_FAMILIES: dict[str, frozenset[str]] = {
48
+ "chat": frozenset({"chat.roster", "chat.start", "chat.say", "chat.end"}),
49
+ "knowledge": frozenset({"knowledge.roster", "knowledge.push", "knowledge.eval"}),
50
+ "memory": frozenset({"memory.roster", "memory.eval", "memory.extraction"}),
51
+ "view": frozenset({"view.render"}),
52
+ }
53
+
54
+
55
+ EVERY_VERB: frozenset[str] = frozenset(get_args(DevVerb.__value__))
56
+
57
+
58
+ # FastAPI builds it: the body, and the socket the query names.
59
+ @dataclass(frozen=True)
60
+ class Ask:
61
+ """What a console asks an app, and which of the agent's sockets it asks."""
62
+
63
+ said: JsonObject
64
+ app: Annotated[str | None, Query()] = None
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Relay:
69
+ """Which family's verb, of which agent."""
70
+
71
+ family: str
72
+ slug: str
73
+ verb: str
74
+
75
+
76
+ # Every verb no other family names is an eval's.
77
+ FAMILIES: dict[str, frozenset[str]] = {
78
+ **NAMED_FAMILIES,
79
+ "evals": EVERY_VERB - frozenset().union(*NAMED_FAMILIES.values()),
80
+ }
81
+
82
+
83
+ AskDep = Annotated[Ask, Depends(Ask)]
84
+
85
+
86
+ @router.post("/v1/agents/{slug}/dev/chat/{verb}")
87
+ async def relay_chat(
88
+ slug: str, verb: str, ask: AskDep, key: _deps.TalkKey, gateway: GatewayDep
89
+ ) -> JsonObject:
90
+ """A chat verb, answered by the process that answers the console for the agent."""
91
+ return await _relayed(gateway, key, Relay("chat", slug, verb), ask)
92
+
93
+
94
+ @router.post("/v1/agents/{slug}/dev/knowledge/{verb}")
95
+ async def relay_knowledge(
96
+ slug: str, verb: str, ask: AskDep, key: _deps.KnowledgeKey, gateway: GatewayDep
97
+ ) -> JsonObject:
98
+ """A knowledge verb, answered by the process that answers the console for the agent."""
99
+ return await _relayed(gateway, key, Relay("knowledge", slug, verb), ask)
100
+
101
+
102
+ @router.post("/v1/agents/{slug}/dev/memory/{verb}")
103
+ async def relay_memory(
104
+ slug: str, verb: str, ask: AskDep, key: _deps.MemoryKey, gateway: GatewayDep
105
+ ) -> JsonObject:
106
+ """A memory verb, answered by the process that answers the console for the agent."""
107
+ return await _relayed(gateway, key, Relay("memory", slug, verb), ask)
108
+
109
+
110
+ @router.post("/v1/agents/{slug}/dev/view/{verb}")
111
+ async def relay_view(
112
+ slug: str, verb: str, ask: AskDep, key: CallsKey, gateway: GatewayDep
113
+ ) -> JsonObject:
114
+ """The side panel beside a conversation, rendered by the app serving the call."""
115
+ return await _relayed(gateway, key, Relay("view", slug, verb), ask)
116
+
117
+
118
+ @router.post("/v1/agents/{slug}/dev/evals/{verb}")
119
+ async def relay_evals(
120
+ slug: str, verb: str, ask: AskDep, key: _deps.EvalsKey, gateway: GatewayDep
121
+ ) -> JsonObject:
122
+ """An evals verb, answered by the process that answers the console for the agent."""
123
+ return await _relayed(gateway, key, Relay("evals", slug, verb), ask)
124
+
125
+
126
+ # dev.request goes down the socket unstored; the app's refusal comes back as its own status.
127
+ async def _relayed(gateway: Gateway, key: Acting, relay: Relay, params: Ask) -> JsonObject:
128
+ if relay.verb not in FAMILIES[relay.family]:
129
+ verbs = sorted(FAMILIES[relay.family])
130
+ raise NotFound(NO_SUCH_VERB.format(verb=relay.verb, family=relay.family, verbs=verbs))
131
+ registration = _answering(gateway, keys.scope_of(key.bearer, key.env), relay, params.app)
132
+ ask_id = f"dev_{uuid4().hex[:12]}"
133
+ request = DevRequest.model_validate({"id": ask_id, "verb": relay.verb, "data": params.said})
134
+ waiting = await gateway.live.ask(ask_id)
135
+ if not await gateway.live.tell(
136
+ registration.owner, _deps.ephemeral_entry(relay.slug, request, kind="dev.request")
137
+ ):
138
+ gateway.live.pending_answers.pop(ask_id, None)
139
+ waiting.cancel()
140
+ raise AppRefused(502, APP_LEFT.format(slug=relay.slug, verb=relay.verb))
141
+ try:
142
+ answer = await asyncio.wait_for(waiting, ANSWERED_WITHIN_S)
143
+ except TimeoutError:
144
+ gateway.live.pending_answers.pop(ask_id, None)
145
+ waiting.cancel()
146
+ said_late = NO_ANSWER.format(slug=relay.slug, verb=relay.verb, seconds=ANSWERED_WITHIN_S)
147
+ raise AppRefused(504, said_late) from None
148
+ if answer.refused is not None:
149
+ raise AppRefused(answer.refused.status, answer.refused.detail)
150
+ return answer.result or {}
151
+
152
+
153
+ # The panel is drawn by the class serving the call; every other verb by the CLI beside it.
154
+ def _answering(gateway: Gateway, where: Scope, relay: Relay, app: str | None) -> Registration:
155
+ sockets = gateway.sockets
156
+ if app is not None:
157
+ named = sockets.serving(where, relay.slug, app)
158
+ if named is None:
159
+ raise Conflict(NOT_THAT_APP.format(app=app, slug=relay.slug))
160
+ return named
161
+ if relay.family == "view":
162
+ found, refusal = sockets.serving(where, relay.slug, None), NO_UNCLAIMED
163
+ else:
164
+ found, refusal = sockets.answering_dev(where, relay.slug), NO_DEV_ANSWERER
165
+ if found is not None:
166
+ return found
167
+ if sockets.of(where, relay.slug) is not None:
168
+ raise Conflict(refusal.format(slug=relay.slug))
169
+ raise NotFound(NO_AGENT.format(slug=relay.slug))