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
+ """What a call's usage cost in US dollars, from the rates of the box's configuration."""
2
+
3
+ import csv
4
+ import math
5
+ from collections.abc import Iterable, Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ from pinecall.domain.call import PhoneLeg
10
+ from pinecall.domain.errors import DeclarationRefused
11
+ from pinecall.providers.catalog import Providers, Rate, longest_prefix
12
+ from pinecall.wire.metrics import LLMModelUsage, ModelUsage, STTModelUsage, TTSModelUsage
13
+ from pinecall.wire.parts import PLATFORM, Cost, CostRow, UnpricedRow
14
+
15
+ type Unit = Literal["input_tokens", "cached_input_tokens", "cache_creation_tokens", "output_tokens"]
16
+
17
+
18
+ A_MILLION = 1_000_000
19
+
20
+ A_MINUTE_S = 60
21
+
22
+ # A prices file's unit, and the field of `Rate` it fills.
23
+ FIELD_OF_UNIT = {
24
+ "input_tokens": "input",
25
+ "output_tokens": "output",
26
+ "cached_input_tokens": "cached_input",
27
+ "cache_creation_tokens": "cache_creation",
28
+ "characters": "characters",
29
+ "audio_seconds": "audio_seconds",
30
+ "minutes": "minutes",
31
+ }
32
+
33
+ COLUMNS = ("vendor", "model", "unit", "usd", "as_of", "source")
34
+
35
+ # The box's own compute, priced by the operator as `pinecall,pinecall-compute,minutes,<usd>` in
36
+ # the prices file: a call's seconds on the worker, every one counted, never minutes begun. With no
37
+ # such row nothing is priced and nothing is listed: a box that never priced itself is not unpriced.
38
+ COMPUTE = "pinecall-compute"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class RatesChange:
43
+ """What writing a prices file would do to the box's rates, model by model."""
44
+
45
+ added: tuple[str, ...]
46
+ changed: tuple[str, ...]
47
+ unchanged: tuple[str, ...]
48
+ # Kept as they are: a file adds and replaces, it never removes.
49
+ only_on_the_box: tuple[str, ...]
50
+
51
+
52
+ def cost(
53
+ usage: Iterable[ModelUsage],
54
+ configured: Providers,
55
+ *,
56
+ legs: Sequence[PhoneLeg] = (),
57
+ seconds: float = 0.0,
58
+ ) -> Cost:
59
+ """Price every usage row, phone leg and the call's seconds of compute; no rate is unpriced."""
60
+ rows: list[CostRow] = []
61
+ unpriced: list[UnpricedRow] = []
62
+ for used in usage:
63
+ priced = _priced(used, configured.rates)
64
+ if priced is None:
65
+ unpriced.append(UnpricedRow(provider=used.provider, model=used.model))
66
+ else:
67
+ rows += priced
68
+ for leg in legs:
69
+ billed = _leg_row(leg, configured.rates)
70
+ if billed is None:
71
+ unpriced.append(UnpricedRow(provider=leg.carrier, model=_leg_name(leg)))
72
+ elif billed.quantity > 0:
73
+ rows.append(billed)
74
+ compute = _compute_row(seconds, configured.rates)
75
+ if compute is not None:
76
+ rows.append(compute)
77
+ return Cost(usd=round(sum(row.usd for row in rows), 6), rows=rows, unpriced=unpriced)
78
+
79
+
80
+ def rate_of(rates: Mapping[str, Rate], model: str) -> Rate | None:
81
+ """The rate whose key is the longest prefix of the model id, so snapshots price by family."""
82
+ key = longest_prefix(rates, model)
83
+ return None if key is None else rates[key]
84
+
85
+
86
+ def rates_from_csv(text: str) -> dict[str, Rate]:
87
+ """The rates of a prices file, one row per model and unit; `#` lines are notes."""
88
+ lines = [line for line in text.splitlines() if not line.startswith("#")]
89
+ reader = csv.DictReader(lines)
90
+ if tuple(reader.fieldnames or ()) != COLUMNS:
91
+ raise DeclarationRefused(f"a prices file has the columns {','.join(COLUMNS)}")
92
+ fields: dict[str, dict[str, float | str]] = {}
93
+ vendors: dict[str, str] = {}
94
+ for number, row in enumerate(reader, start=2):
95
+ model, unit = row["model"], row["unit"]
96
+ field = FIELD_OF_UNIT.get(unit)
97
+ if field is None:
98
+ raise DeclarationRefused(
99
+ f"line {number}: {unit!r} is not a unit; one of {', '.join(FIELD_OF_UNIT)}"
100
+ )
101
+ if vendors.setdefault(model, row["vendor"]) != row["vendor"]:
102
+ raise DeclarationRefused(
103
+ f"line {number}: {model} is {vendors[model]}'s already: a model is priced once"
104
+ )
105
+ priced = fields.setdefault(model, {"as_of": ""})
106
+ if field in priced:
107
+ raise DeclarationRefused(f"line {number}: {model} {unit} is priced twice")
108
+ priced[field] = _usd(row["usd"], number)
109
+ priced["as_of"] = max(str(priced["as_of"]), row["as_of"])
110
+ return {model: Rate.model_validate(priced) for model, priced in fields.items()}
111
+
112
+
113
+ def rates_changed(box: Mapping[str, Rate], written: Mapping[str, Rate]) -> RatesChange:
114
+ """Each model of the file as new, changed or the same, and what only the box holds."""
115
+ return RatesChange(
116
+ added=tuple(sorted(model for model in written if model not in box)),
117
+ changed=tuple(
118
+ sorted(model for model in written if model in box and box[model] != written[model])
119
+ ),
120
+ unchanged=tuple(sorted(model for model in written if box.get(model) == written[model])),
121
+ only_on_the_box=tuple(sorted(model for model in box if model not in written)),
122
+ )
123
+
124
+
125
+ # Interruption and end-of-turn models run inside livekit and cost nothing: no row, not unpriced.
126
+ def _priced(used: ModelUsage, rates: Mapping[str, Rate]) -> list[CostRow] | None:
127
+ rate = rate_of(rates, used.model)
128
+ match used:
129
+ case LLMModelUsage():
130
+ return None if rate is None or rate.input is None else _tokens(used, rate)
131
+ case TTSModelUsage():
132
+ per = None if rate is None else rate.characters
133
+ return _counted(used, "characters", quantity=used.characters_count or 0, per=per)
134
+ case STTModelUsage():
135
+ per = None if rate is None else rate.audio_seconds
136
+ return _counted(used, "audio_seconds", quantity=used.audio_duration or 0.0, per=per)
137
+ case _:
138
+ return []
139
+
140
+
141
+ # Plugins count cached and cache-written tokens inside input_tokens, so both come off the
142
+ # fresh input before it is priced.
143
+ def _tokens(used: LLMModelUsage, rate: Rate) -> list[CostRow]:
144
+ cached = used.input_cached_tokens or 0
145
+ written = used.input_cache_creation_tokens or 0
146
+ counted: list[tuple[Unit, int, float | None]] = [
147
+ ("input_tokens", max((used.input_tokens or 0) - cached - written, 0), rate.input),
148
+ (
149
+ "cached_input_tokens",
150
+ cached,
151
+ rate.input if rate.cached_input is None else rate.cached_input,
152
+ ),
153
+ ("cache_creation_tokens", written, rate.cache_creation),
154
+ ("output_tokens", used.output_tokens or 0, rate.output),
155
+ ]
156
+ return [
157
+ _row(used, unit, quantity=tokens, per=per, usd=tokens / A_MILLION * per)
158
+ for unit, tokens, per in counted
159
+ if tokens > 0 and per is not None
160
+ ]
161
+
162
+
163
+ def _counted(
164
+ used: TTSModelUsage | STTModelUsage,
165
+ unit: Literal["characters", "audio_seconds"],
166
+ *,
167
+ quantity: float,
168
+ per: float | None,
169
+ ) -> list[CostRow] | None:
170
+ if per is None:
171
+ return None
172
+ if quantity <= 0:
173
+ return []
174
+ return [_row(used, unit, quantity=quantity, per=per, usd=quantity * per)]
175
+
176
+
177
+ # A token row carries its price per million tokens, as a pricing page prints it.
178
+ def _row(
179
+ used: ModelUsage,
180
+ unit: Unit | Literal["characters", "audio_seconds"],
181
+ *,
182
+ quantity: float,
183
+ per: float,
184
+ usd: float,
185
+ ) -> CostRow:
186
+ return CostRow(
187
+ provider=used.provider,
188
+ model=used.model,
189
+ unit=unit,
190
+ quantity=quantity,
191
+ unit_price_usd=per,
192
+ usd=round(usd, 6),
193
+ )
194
+
195
+
196
+ # A carrier prices a leg by the longest prefix of its number, as its own rate tables do; the row
197
+ # names the prefix it matched, never the number.
198
+ def _leg_row(leg: PhoneLeg, rates: Mapping[str, Rate]) -> CostRow | None:
199
+ key = longest_prefix(rates, f"{_leg_name(leg)}/{leg.number}")
200
+ per = None if key is None else rates[key].minutes
201
+ if key is None or per is None:
202
+ return None
203
+ minutes = math.ceil(leg.seconds / A_MINUTE_S) if leg.seconds > 0 else 0
204
+ return CostRow(
205
+ provider=leg.carrier,
206
+ model=key,
207
+ unit="minutes",
208
+ quantity=minutes,
209
+ unit_price_usd=per,
210
+ usd=round(minutes * per, 6),
211
+ )
212
+
213
+
214
+ def _compute_row(seconds: float, rates: Mapping[str, Rate]) -> CostRow | None:
215
+ rate = rates.get(COMPUTE)
216
+ if seconds <= 0 or rate is None or rate.minutes is None:
217
+ return None
218
+ minutes = round(seconds / A_MINUTE_S, 4)
219
+ return CostRow(
220
+ provider=PLATFORM,
221
+ model=COMPUTE,
222
+ unit="minutes",
223
+ quantity=minutes,
224
+ unit_price_usd=rate.minutes,
225
+ usd=round(minutes * rate.minutes, 6),
226
+ )
227
+
228
+
229
+ def _leg_name(leg: PhoneLeg) -> str:
230
+ return f"{leg.carrier}-{leg.direction}"
231
+
232
+
233
+ def _usd(text: str, number: int) -> float:
234
+ try:
235
+ usd = float(text)
236
+ except ValueError:
237
+ raise DeclarationRefused(f"line {number}: {text!r} is not a price in dollars") from None
238
+ if usd < 0:
239
+ raise DeclarationRefused(f"line {number}: a price is never below zero")
240
+ return usd
@@ -0,0 +1,104 @@
1
+ """A stage a setting changes, tried once outside a call: a vendor's no is heard where it is set."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ from typing import Never
6
+
7
+ from livekit import rtc
8
+ from livekit.agents import APIConnectionError, APIConnectOptions, APIError, APITimeoutError, stt
9
+ from livekit.agents.llm import ChatContext
10
+ from livekit.agents.utils import http_context
11
+
12
+ from pinecall.domain.agent import Turn
13
+ from pinecall.domain.errors import DeclarationRefused, UpstreamFailed
14
+ from pinecall.providers import voices
15
+ from pinecall.providers.build import Modality, Running, llm_of, stt_of
16
+
17
+ # A vendor slower than this to say a word is not refusing the setting, it is not answering.
18
+ TRY_S = 12.0
19
+
20
+ LINE = "OK."
21
+
22
+ # Ears that keep listening once the input ends took the connection: a refusal comes back sooner.
23
+ LISTENED_S = 4.0
24
+
25
+ # Half a second of silence at the rate every plugin takes, to open the ears and close them.
26
+ RATE = 16_000
27
+
28
+ REFUSED = "{vendor} refused the {stage} this sets: {why}"
29
+
30
+ UNANSWERED = (
31
+ "{vendor} did not answer while the {stage} this sets was tried: {why}; nothing was kept, "
32
+ "try again"
33
+ )
34
+
35
+ STAGE_NAMES: dict[Modality, str] = {"llm": "model", "stt": "ears", "tts": "voice"}
36
+
37
+ # Once, at once: a setting is not worth livekit's retries, and a person is waiting on the answer.
38
+ ONCE = APIConnectOptions(max_retry=0, timeout=10.0)
39
+
40
+ SILENCE = rtc.AudioFrame(
41
+ data=bytes(RATE), sample_rate=RATE, num_channels=1, samples_per_channel=RATE // 2
42
+ )
43
+
44
+
45
+ async def tried(stage: Modality, running: Running, turn: Turn | None = None) -> None:
46
+ """The stage asked one small thing; the vendor's no refused, its silence UpstreamFailed."""
47
+ named = STAGE_NAMES[stage]
48
+ try:
49
+ # Outside a call there is no job to lend the plugins its HTTP session: it is opened here.
50
+ async with asyncio.timeout(TRY_S), http_context.open():
51
+ if stage == "tts":
52
+ await voices.sample(running, LINE)
53
+ elif stage == "llm":
54
+ await _answered(running)
55
+ else:
56
+ await _heard(stt_of(running, turn))
57
+ except TimeoutError as slow:
58
+ why = f"no answer in {TRY_S:.0f} s"
59
+ unanswered = UNANSWERED.format(vendor=running.vendor, stage=named, why=why)
60
+ raise UpstreamFailed(unanswered) from slow
61
+ except (APIConnectionError, APITimeoutError) as silent:
62
+ unanswered = UNANSWERED.format(vendor=running.vendor, stage=named, why=silent.message)
63
+ raise UpstreamFailed(unanswered) from silent
64
+ except APIError as refused:
65
+ refusal = REFUSED.format(vendor=running.vendor, stage=named, why=refused.message)
66
+ raise DeclarationRefused(refusal) from refused
67
+ except UpstreamFailed as failed:
68
+ cause = failed.__cause__
69
+ if isinstance(cause, APIConnectionError | APITimeoutError):
70
+ raise UpstreamFailed(
71
+ UNANSWERED.format(vendor=running.vendor, stage=named, why=cause.message)
72
+ ) from cause
73
+ raise DeclarationRefused(
74
+ REFUSED.format(vendor=running.vendor, stage=named, why=str(failed))
75
+ ) from failed
76
+
77
+
78
+ async def _answered(running: Running) -> None:
79
+ model = llm_of(running)
80
+ context = ChatContext.empty()
81
+ context.add_message(role="user", content=LINE)
82
+ try:
83
+ async with model.chat(chat_ctx=context, conn_options=ONCE) as stream:
84
+ async for _ in stream:
85
+ break
86
+ finally:
87
+ await model.aclose()
88
+
89
+
90
+ # Ears that only take a whole utterance have nothing to open: they are tried by the first call.
91
+ # The turn's knobs are the call's, so a silence or a bar the vendor does not take is its no here.
92
+ async def _heard(ears: stt.STT[Never]) -> None:
93
+ try:
94
+ if not ears.capabilities.streaming:
95
+ return
96
+ async with ears.stream(conn_options=ONCE) as stream:
97
+ stream.push_frame(SILENCE)
98
+ stream.end_input()
99
+ with contextlib.suppress(TimeoutError):
100
+ async with asyncio.timeout(LISTENED_S):
101
+ async for _ in stream:
102
+ continue
103
+ finally:
104
+ await ears.aclose()
@@ -0,0 +1,174 @@
1
+ """The voice picker: the voices a vendor lists, and a line spoken by one of them outside a call."""
2
+
3
+ import dataclasses
4
+ import inspect
5
+ import io
6
+ import time
7
+ import wave
8
+ from collections.abc import AsyncIterator, Mapping
9
+ from dataclasses import dataclass
10
+
11
+ from livekit.agents import APIError, APIStatusError
12
+ from livekit.agents.tts import SynthesizedAudio
13
+ from livekit.agents.utils import http_context
14
+
15
+ from pinecall.domain.errors import NotFound, UpstreamFailed
16
+ from pinecall.domain.names import Json, JsonObject
17
+ from pinecall.providers.build import Running, a_list, a_mapping, tts_of
18
+ from pinecall.providers.catalog import Providers
19
+
20
+ SAMPLE_WIDTH = 2 # 16-bit PCM, as every plugin emits it
21
+
22
+ # Plugins name a voice's id and name differently; the first a row carries is it.
23
+ _THE_ID = ("id", "voice_id", "voice")
24
+ _THE_NAME = ("name", "voice_name", "display_name")
25
+
26
+ NOT_LISTED = "{vendor} lists no voices here: its voice is the vendor's own id, set as it is"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ListedVoice:
31
+ """A voice as the picker shows it: its id, its name, and the vendor's row whole."""
32
+
33
+ id: str
34
+ name: str
35
+ detail: JsonObject
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Sample:
40
+ """A line spoken as WAV, with when its first audio came and when it ended."""
41
+
42
+ wav: bytes
43
+ # Includes setting up the connection, which a call already warmed.
44
+ first_audio_ms: int
45
+ total_ms: int
46
+
47
+
48
+ async def voices(running: Running) -> list[ListedVoice]:
49
+ """The voices a vendor lists on the stage's key, where its plugin lists any; NotFound if not."""
50
+ async with http_context.open():
51
+ speech = tts_of(running)
52
+ listing = getattr(speech, "list_voices", None)
53
+ if not callable(listing):
54
+ raise NotFound(NOT_LISTED.format(vendor=running.vendor))
55
+ try:
56
+ params = listing()
57
+ if not inspect.isawaitable(params):
58
+ raise NotFound(NOT_LISTED.format(vendor=running.vendor))
59
+ answered: object = await params
60
+ except APIError as refused:
61
+ raise _refused(running.vendor, "did not list its voices", refused) from refused
62
+ finally:
63
+ await speech.aclose()
64
+ return [voice for row in _rows(answered) if (voice := _listed(row)) is not None]
65
+
66
+
67
+ def kept(configured: Providers, vendor: str, language: str | None) -> list[ListedVoice]:
68
+ """The voices the providers row lists for a vendor, in that language or in every one."""
69
+ found: list[ListedVoice] = []
70
+ for key, listed in configured.listed.items():
71
+ named, _, spoken = key.partition("/")
72
+ if named != vendor or (language is not None and spoken != language):
73
+ continue
74
+ for voice in listed:
75
+ detail: JsonObject = {**voice.model_dump(), "language": spoken}
76
+ found.append(ListedVoice(id=voice.id, name=voice.name, detail=detail))
77
+ return found
78
+
79
+
80
+ def lists_any(configured: Providers, vendor: str) -> bool:
81
+ """Whether the providers row lists a voice of this vendor in any language."""
82
+ return any(key.partition("/")[0] == vendor for key in configured.listed)
83
+
84
+
85
+ async def sample(running: Running, line: str) -> Sample:
86
+ """The line spoken by the stage's voice, built as a call builds it, or the vendor's words."""
87
+ async with http_context.open():
88
+ speech = tts_of(running)
89
+ try:
90
+ if speech.capabilities.streaming:
91
+ async with speech.stream() as stream:
92
+ stream.push_text(line)
93
+ stream.end_input()
94
+ return await _heard(stream)
95
+ async with speech.synthesize(line) as chunks:
96
+ return await _heard(chunks)
97
+ except APIError as refused:
98
+ raise _refused(running.vendor, "did not say it", refused) from refused
99
+ finally:
100
+ await speech.aclose()
101
+
102
+
103
+ # The vendor's own words, and its status where it answered with one.
104
+ def _refused(vendor: str, what: str, error: APIError) -> UpstreamFailed:
105
+ status = f" ({error.status_code})" if isinstance(error, APIStatusError) else ""
106
+ return UpstreamFailed(f"{vendor} {what}{status}: {error.message}")
107
+
108
+
109
+ # A listing is a list of rows, each a dataclass or a mapping; one that is a mapping of names
110
+ # is read as rows named by its keys.
111
+ def _rows(answered: object) -> list[JsonObject]:
112
+ if a_mapping(answered):
113
+ return [{"id": key, "name": key} for key in answered]
114
+ return [_row(item) for item in answered] if a_list(answered) else []
115
+
116
+
117
+ def _row(item: object) -> JsonObject:
118
+ if dataclasses.is_dataclass(item) and not isinstance(item, type):
119
+ return _json(dataclasses.asdict(item))
120
+ return _json(item) if a_mapping(item) else {}
121
+
122
+
123
+ def _json(fields: Mapping[str, object]) -> JsonObject:
124
+ return {name: _value(value) for name, value in fields.items()}
125
+
126
+
127
+ def _value(value: object) -> Json:
128
+ if isinstance(value, str | int | float | bool) or value is None:
129
+ return value
130
+ if a_mapping(value):
131
+ return _json(value)
132
+ if a_list(value):
133
+ return [_value(item) for item in value]
134
+ return str(value)
135
+
136
+
137
+ def _listed(row: JsonObject) -> ListedVoice | None:
138
+ named = next((str(row[name]) for name in _THE_ID if row.get(name)), None)
139
+ if named is None:
140
+ return None
141
+ shown = next((str(row[name]) for name in _THE_NAME if row.get(name)), named)
142
+ return ListedVoice(id=named, name=shown, detail=row)
143
+
144
+
145
+ # livekit refuses a synthesis that pushed no audio, so what reaches here has some.
146
+ async def _heard(audio: AsyncIterator[SynthesizedAudio]) -> Sample:
147
+ started = time.perf_counter()
148
+ first: float | None = None
149
+ pcm = bytearray()
150
+ rate, channels = 0, 0
151
+ async for chunk in audio:
152
+ first = first or time.perf_counter()
153
+ pcm += bytes(chunk.frame.data)
154
+ rate, channels = chunk.frame.sample_rate, chunk.frame.num_channels
155
+ ended = time.perf_counter()
156
+ return Sample(
157
+ wav=_wav(bytes(pcm), rate, channels),
158
+ first_audio_ms=_ms(started, first or ended),
159
+ total_ms=_ms(started, ended),
160
+ )
161
+
162
+
163
+ def _wav(pcm: bytes, rate: int, channels: int) -> bytes:
164
+ out = io.BytesIO()
165
+ with wave.open(out, "wb") as file:
166
+ file.setnchannels(channels)
167
+ file.setsampwidth(SAMPLE_WIDTH)
168
+ file.setframerate(rate)
169
+ file.writeframes(pcm)
170
+ return out.getvalue()
171
+
172
+
173
+ def _ms(started: float, at: float) -> int:
174
+ return round((at - started) * 1000)