pinecall 0.1.0__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 (366) hide show
  1. pinecall/__init__.py +9 -0
  2. pinecall/_env_example.py +38 -0
  3. pinecall/_env_files.py +31 -0
  4. pinecall/_exceptions.py +5 -0
  5. pinecall/_settings.py +387 -0
  6. pinecall/_vendor_keys.py +283 -0
  7. pinecall/_version.py +4 -0
  8. pinecall/api/__init__.py +1 -0
  9. pinecall/api/_deps.py +384 -0
  10. pinecall/api/_live.py +246 -0
  11. pinecall/api/_operator.py +50 -0
  12. pinecall/api/_refusals.py +40 -0
  13. pinecall/api/_serving.py +49 -0
  14. pinecall/api/agents/__init__.py +1 -0
  15. pinecall/api/agents/dev.py +177 -0
  16. pinecall/api/agents/doors.py +136 -0
  17. pinecall/api/agents/endpoints.py +164 -0
  18. pinecall/api/agents/handlers.py +120 -0
  19. pinecall/api/agents/holding.py +71 -0
  20. pinecall/api/agents/on_a_call.py +146 -0
  21. pinecall/api/agents/provider_keys.py +28 -0
  22. pinecall/api/agents/registry.py +399 -0
  23. pinecall/api/agents/socket.py +240 -0
  24. pinecall/api/app.py +312 -0
  25. pinecall/api/calls/__init__.py +1 -0
  26. pinecall/api/calls/chat.py +236 -0
  27. pinecall/api/calls/commands.py +52 -0
  28. pinecall/api/calls/events.py +395 -0
  29. pinecall/api/calls/listing.py +86 -0
  30. pinecall/api/calls/lookup.py +45 -0
  31. pinecall/api/calls/opening.py +98 -0
  32. pinecall/api/calls/recording.py +46 -0
  33. pinecall/api/calls/sink.py +281 -0
  34. pinecall/api/calls/state.py +48 -0
  35. pinecall/api/calls/tools.py +85 -0
  36. pinecall/api/contacts.py +124 -0
  37. pinecall/api/discovery.py +41 -0
  38. pinecall/api/evals/__init__.py +1 -0
  39. pinecall/api/evals/attachment.py +83 -0
  40. pinecall/api/evals/caller.py +31 -0
  41. pinecall/api/evals/conversation.py +179 -0
  42. pinecall/api/evals/listening.py +93 -0
  43. pinecall/api/evals/replay.py +75 -0
  44. pinecall/api/evals/runner.py +329 -0
  45. pinecall/api/evals/runs.py +124 -0
  46. pinecall/api/evals/scoring.py +128 -0
  47. pinecall/api/evals/settling.py +43 -0
  48. pinecall/api/evals/spoken.py +96 -0
  49. pinecall/api/evals/voice.py +100 -0
  50. pinecall/api/extraction.py +97 -0
  51. pinecall/api/fleet.py +157 -0
  52. pinecall/api/floor.py +51 -0
  53. pinecall/api/keys.py +117 -0
  54. pinecall/api/knowledge.py +136 -0
  55. pinecall/api/listen.py +24 -0
  56. pinecall/api/login.py +196 -0
  57. pinecall/api/managed.py +100 -0
  58. pinecall/api/members.py +266 -0
  59. pinecall/api/numbers.py +362 -0
  60. pinecall/api/orgs.py +274 -0
  61. pinecall/api/pages.py +84 -0
  62. pinecall/api/pairing.py +88 -0
  63. pinecall/api/pipeline.py +56 -0
  64. pinecall/api/pipeline_report.py +168 -0
  65. pinecall/api/provider_keys.py +99 -0
  66. pinecall/api/providers.py +95 -0
  67. pinecall/api/routes.py +114 -0
  68. pinecall/api/signup.py +115 -0
  69. pinecall/api/supervise/__init__.py +1 -0
  70. pinecall/api/supervise/aiming.py +148 -0
  71. pinecall/api/supervise/verbs.py +51 -0
  72. pinecall/api/supervise_seat.py +28 -0
  73. pinecall/api/tokens.py +184 -0
  74. pinecall/api/usage.py +103 -0
  75. pinecall/api/whatsapp/__init__.py +1 -0
  76. pinecall/api/whatsapp/doors.py +38 -0
  77. pinecall/api/whatsapp/threads.py +236 -0
  78. pinecall/api/whatsapp/webhook.py +119 -0
  79. pinecall/api/whoami.py +100 -0
  80. pinecall/auth/__init__.py +21 -0
  81. pinecall/auth/bearer.py +30 -0
  82. pinecall/auth/codes.py +62 -0
  83. pinecall/auth/invitations.py +30 -0
  84. pinecall/auth/keys.py +378 -0
  85. pinecall/auth/members.py +390 -0
  86. pinecall/auth/pairing.py +115 -0
  87. pinecall/auth/passwords.py +37 -0
  88. pinecall/auth/scopes.py +240 -0
  89. pinecall/auth/throttle.py +43 -0
  90. pinecall/cli/__init__.py +87 -0
  91. pinecall/cli/box/__init__.py +5 -0
  92. pinecall/cli/box/verbs.py +136 -0
  93. pinecall/cli/columns.py +12 -0
  94. pinecall/cli/doctor/__init__.py +5 -0
  95. pinecall/cli/doctor/probes.py +58 -0
  96. pinecall/cli/doctor/verbs.py +339 -0
  97. pinecall/cli/fleet/__init__.py +5 -0
  98. pinecall/cli/fleet/verbs.py +206 -0
  99. pinecall/cli/gateway.py +36 -0
  100. pinecall/cli/init.py +106 -0
  101. pinecall/cli/keys/__init__.py +5 -0
  102. pinecall/cli/keys/verbs.py +193 -0
  103. pinecall/cli/migrate.py +103 -0
  104. pinecall/cli/operator.py +125 -0
  105. pinecall/cli/orgs/__init__.py +5 -0
  106. pinecall/cli/orgs/verbs.py +340 -0
  107. pinecall/cli/providers/__init__.py +5 -0
  108. pinecall/cli/providers/verbs.py +61 -0
  109. pinecall/cli/routes/__init__.py +5 -0
  110. pinecall/cli/routes/verbs.py +187 -0
  111. pinecall/cli/sessions/__init__.py +5 -0
  112. pinecall/cli/sessions/render.py +163 -0
  113. pinecall/cli/sessions/source.py +55 -0
  114. pinecall/cli/sessions/verbs.py +266 -0
  115. pinecall/cli/worker.py +148 -0
  116. pinecall/evals/__init__.py +76 -0
  117. pinecall/evals/answers.py +39 -0
  118. pinecall/evals/bridge.py +186 -0
  119. pinecall/evals/caller.py +151 -0
  120. pinecall/evals/calling.py +298 -0
  121. pinecall/evals/case.py +117 -0
  122. pinecall/evals/checks/__init__.py +1 -0
  123. pinecall/evals/checks/consent.py +48 -0
  124. pinecall/evals/checks/errors.py +24 -0
  125. pinecall/evals/checks/latency.py +41 -0
  126. pinecall/evals/checks/register.py +43 -0
  127. pinecall/evals/checks/replayed.py +90 -0
  128. pinecall/evals/checks/verdict.py +44 -0
  129. pinecall/evals/goldens.py +79 -0
  130. pinecall/evals/headless.py +104 -0
  131. pinecall/evals/judges/__init__.py +1 -0
  132. pinecall/evals/judges/asking.py +86 -0
  133. pinecall/evals/judges/consent.py +53 -0
  134. pinecall/evals/judges/expected.py +201 -0
  135. pinecall/evals/judges/grounded.py +237 -0
  136. pinecall/evals/judges/model.py +77 -0
  137. pinecall/evals/judges/policy.py +53 -0
  138. pinecall/evals/judges/register.py +68 -0
  139. pinecall/evals/line.py +89 -0
  140. pinecall/evals/matrix.py +144 -0
  141. pinecall/evals/polling.py +22 -0
  142. pinecall/evals/remembering.py +33 -0
  143. pinecall/evals/report.py +120 -0
  144. pinecall/evals/runs.py +179 -0
  145. pinecall/evals/score.py +160 -0
  146. pinecall/evals/speech.py +94 -0
  147. pinecall/evals/transcript.py +39 -0
  148. pinecall/evals/verdicts.py +75 -0
  149. pinecall/extensions/__init__.py +6 -0
  150. pinecall/extensions/loading.py +43 -0
  151. pinecall/extensions/points.py +28 -0
  152. pinecall/fleet/__init__.py +37 -0
  153. pinecall/fleet/clouds.py +88 -0
  154. pinecall/fleet/decisions.py +168 -0
  155. pinecall/fleet/loop.py +104 -0
  156. pinecall/fleet/roster.py +151 -0
  157. pinecall/gateway/admin/assets/index-C3WMXdfL.css +1 -0
  158. pinecall/gateway/admin/assets/index-DR1mxoAh.js +58 -0
  159. pinecall/gateway/admin/index.html +20 -0
  160. pinecall/gateway/console/assets/index-DCTnou52.css +1 -0
  161. pinecall/gateway/console/assets/index-dOcE4usU.js +99 -0
  162. pinecall/gateway/console/index.html +20 -0
  163. pinecall/gateway/console/logo-mark.png +0 -0
  164. pinecall/knowledge/__init__.py +6 -0
  165. pinecall/knowledge/chunking.py +111 -0
  166. pinecall/knowledge/protocol.py +69 -0
  167. pinecall/knowledge/scoring.py +87 -0
  168. pinecall/knowledge/store.py +267 -0
  169. pinecall/log/__init__.py +33 -0
  170. pinecall/log/entry.py +38 -0
  171. pinecall/log/fanout.py +108 -0
  172. pinecall/log/filters.py +67 -0
  173. pinecall/log/latencies.py +67 -0
  174. pinecall/log/logs.py +139 -0
  175. pinecall/log/pii.py +133 -0
  176. pinecall/log/projection.py +244 -0
  177. pinecall/log/reduce.py +396 -0
  178. pinecall/log/replay.py +166 -0
  179. pinecall/log/room.py +69 -0
  180. pinecall/log/snapshots.py +43 -0
  181. pinecall/log/store/__init__.py +21 -0
  182. pinecall/log/store/memory.py +145 -0
  183. pinecall/log/store/migrating.py +205 -0
  184. pinecall/log/store/pool.py +26 -0
  185. pinecall/log/store/postgres.py +373 -0
  186. pinecall/log/store/protocol.py +94 -0
  187. pinecall/log/usage.py +134 -0
  188. pinecall/log/wording.py +25 -0
  189. pinecall/log/writers.py +126 -0
  190. pinecall/lookups/__init__.py +5 -0
  191. pinecall/lookups/answers.py +44 -0
  192. pinecall/lookups/entries.py +69 -0
  193. pinecall/lookups/service.py +242 -0
  194. pinecall/memory/__init__.py +11 -0
  195. pinecall/memory/extraction.py +231 -0
  196. pinecall/memory/goldens.py +226 -0
  197. pinecall/memory/pgvector.py +324 -0
  198. pinecall/memory/protocol.py +105 -0
  199. pinecall/memory/ranking.py +48 -0
  200. pinecall/memory/scoring.py +99 -0
  201. pinecall/migrations/0001_call_log.sql +62 -0
  202. pinecall/migrations/0002_api_keys.sql +16 -0
  203. pinecall/migrations/0003_routes.sql +19 -0
  204. pinecall/migrations/0004_eval_runs.sql +22 -0
  205. pinecall/migrations/0005_tokens.sql +20 -0
  206. pinecall/migrations/0006_orgs.sql +64 -0
  207. pinecall/migrations/0007_provider_keys.sql +20 -0
  208. pinecall/migrations/0008_memory.sql +46 -0
  209. pinecall/migrations/0009_knowledge.sql +49 -0
  210. pinecall/migrations/0010_memory_model.sql +14 -0
  211. pinecall/migrations/0011_feature_quotas.sql +14 -0
  212. pinecall/migrations/0012_pipeline_overrides.sql +26 -0
  213. pinecall/migrations/0013_environments.sql +39 -0
  214. pinecall/migrations/0014_members.sql +38 -0
  215. pinecall/migrations/0015_carriers.sql +20 -0
  216. pinecall/migrations/0016_managed_numbers.sql +11 -0
  217. pinecall/migrations/0017_provider_scope.sql +16 -0
  218. pinecall/migrations/0018_worlds_in_the_data.sql +50 -0
  219. pinecall/migrations/0019_seats.sql +13 -0
  220. pinecall/migrations/0020_operators.sql +18 -0
  221. pinecall/migrations/0021_a_developers_own.sql +48 -0
  222. pinecall/migrations/0022_a_tts_knob.sql +17 -0
  223. pinecall/migrations/0023_sandbox.sql +42 -0
  224. pinecall/migrations/migrations.lock +11 -0
  225. pinecall/orgs/__init__.py +1 -0
  226. pinecall/orgs/admission.py +135 -0
  227. pinecall/orgs/carriers.py +144 -0
  228. pinecall/orgs/meter.py +46 -0
  229. pinecall/orgs/table.py +198 -0
  230. pinecall/orgs/turned.py +62 -0
  231. pinecall/orgs/vault.py +154 -0
  232. pinecall/providers/__init__.py +1 -0
  233. pinecall/providers/_inference.py +51 -0
  234. pinecall/providers/blocks.py +79 -0
  235. pinecall/providers/catalog.py +303 -0
  236. pinecall/providers/declaration.py +231 -0
  237. pinecall/providers/embed/__init__.py +109 -0
  238. pinecall/providers/embed/perplexity.py +193 -0
  239. pinecall/providers/embed/tei.py +100 -0
  240. pinecall/providers/embed/wire.py +63 -0
  241. pinecall/providers/embedder.py +89 -0
  242. pinecall/providers/knocks.py +39 -0
  243. pinecall/providers/llm/__init__.py +5 -0
  244. pinecall/providers/llm/anthropic.py +20 -0
  245. pinecall/providers/llm/livekit.py +14 -0
  246. pinecall/providers/llm/openai.py +18 -0
  247. pinecall/providers/models.py +59 -0
  248. pinecall/providers/overrides.py +233 -0
  249. pinecall/providers/pipeline.py +108 -0
  250. pinecall/providers/plugin.py +150 -0
  251. pinecall/providers/prices.py +240 -0
  252. pinecall/providers/published.py +71 -0
  253. pinecall/providers/published_prices.json +5344 -0
  254. pinecall/providers/registry.py +193 -0
  255. pinecall/providers/standing.py +40 -0
  256. pinecall/providers/stt/__init__.py +20 -0
  257. pinecall/providers/stt/deepgram.py +26 -0
  258. pinecall/providers/stt/livekit.py +23 -0
  259. pinecall/providers/stt/soniox.py +38 -0
  260. pinecall/providers/tts/__init__.py +5 -0
  261. pinecall/providers/tts/elevenlabs.py +57 -0
  262. pinecall/providers/tts/livekit.py +26 -0
  263. pinecall/providers/tts/voices.py +113 -0
  264. pinecall/providers/usage.py +40 -0
  265. pinecall/routes/__init__.py +1 -0
  266. pinecall/routes/answering.py +89 -0
  267. pinecall/routes/table.py +253 -0
  268. pinecall/routes/trunks.py +160 -0
  269. pinecall/routes/twilio.py +236 -0
  270. pinecall/session/__init__.py +1 -0
  271. pinecall/session/asking.py +71 -0
  272. pinecall/session/clock.py +54 -0
  273. pinecall/session/declaring.py +57 -0
  274. pinecall/session/first_entries.py +41 -0
  275. pinecall/session/greeting.py +33 -0
  276. pinecall/session/knowing.py +22 -0
  277. pinecall/session/lookups.py +284 -0
  278. pinecall/session/pending.py +72 -0
  279. pinecall/session/remembering.py +48 -0
  280. pinecall/session/scoring.py +21 -0
  281. pinecall/session/supervising.py +39 -0
  282. pinecall/session/text/__init__.py +1 -0
  283. pinecall/session/text/agent.py +148 -0
  284. pinecall/session/text/measure.py +92 -0
  285. pinecall/session/text/running.py +45 -0
  286. pinecall/session/text/session.py +328 -0
  287. pinecall/session/text/supervising.py +94 -0
  288. pinecall/session/text/turns.py +144 -0
  289. pinecall/session/visibility.py +46 -0
  290. pinecall/session/voice/__init__.py +5 -0
  291. pinecall/session/voice/agent.py +124 -0
  292. pinecall/session/voice/barge_in.py +63 -0
  293. pinecall/session/voice/commands.py +252 -0
  294. pinecall/session/voice/dead_end.py +23 -0
  295. pinecall/session/voice/events.py +257 -0
  296. pinecall/session/voice/hanging_up.py +75 -0
  297. pinecall/session/voice/hearing.py +59 -0
  298. pinecall/session/voice/kit.py +24 -0
  299. pinecall/session/voice/metrics.py +161 -0
  300. pinecall/session/voice/platform.py +44 -0
  301. pinecall/session/voice/reading_back.py +21 -0
  302. pinecall/session/voice/room/__init__.py +7 -0
  303. pinecall/session/voice/room/datachannel.py +248 -0
  304. pinecall/session/voice/room/facts.py +210 -0
  305. pinecall/session/voice/room/holding.py +50 -0
  306. pinecall/session/voice/room/invite.py +40 -0
  307. pinecall/session/voice/room/mute.py +50 -0
  308. pinecall/session/voice/room/remove.py +21 -0
  309. pinecall/session/voice/room/send.py +22 -0
  310. pinecall/session/voice/session.py +173 -0
  311. pinecall/session/voice/sip.py +83 -0
  312. pinecall/session/voice/supervising.py +139 -0
  313. pinecall/session/voice/tools.py +114 -0
  314. pinecall/session/voice/transfer.py +74 -0
  315. pinecall/session/voice/voice.py +391 -0
  316. pinecall/session/voice/writing.py +70 -0
  317. pinecall/tokens/__init__.py +1 -0
  318. pinecall/tokens/ledger.py +107 -0
  319. pinecall/tokens/room.py +49 -0
  320. pinecall/tokens/seating.py +62 -0
  321. pinecall/tokens/spending.py +37 -0
  322. pinecall/types/__init__.py +152 -0
  323. pinecall/types/agent.py +157 -0
  324. pinecall/types/call.py +93 -0
  325. pinecall/types/carrier.py +88 -0
  326. pinecall/types/channel.py +19 -0
  327. pinecall/types/consent.py +128 -0
  328. pinecall/types/counting.py +11 -0
  329. pinecall/types/dispatch.py +28 -0
  330. pinecall/types/fusion.py +32 -0
  331. pinecall/types/goldens.py +60 -0
  332. pinecall/types/json.py +6 -0
  333. pinecall/types/key.py +120 -0
  334. pinecall/types/knowledge.py +90 -0
  335. pinecall/types/lookup.py +102 -0
  336. pinecall/types/member.py +100 -0
  337. pinecall/types/org.py +117 -0
  338. pinecall/types/prompt.py +99 -0
  339. pinecall/types/provider_keys.py +15 -0
  340. pinecall/types/refused.py +8 -0
  341. pinecall/types/route.py +68 -0
  342. pinecall/types/token.py +139 -0
  343. pinecall/types/tool.py +74 -0
  344. pinecall/whatsapp/__init__.py +1 -0
  345. pinecall/whatsapp/graph.py +97 -0
  346. pinecall/whatsapp/inbound.py +130 -0
  347. pinecall/whatsapp/meta.py +9 -0
  348. pinecall/whatsapp/routing.py +44 -0
  349. pinecall/whatsapp/sending.py +47 -0
  350. pinecall/whatsapp/signing.py +23 -0
  351. pinecall/worker/__init__.py +22 -0
  352. pinecall/worker/client.py +227 -0
  353. pinecall/worker/commanding.py +56 -0
  354. pinecall/worker/entry.py +200 -0
  355. pinecall/worker/heartbeat.py +91 -0
  356. pinecall/worker/load.py +120 -0
  357. pinecall/worker/main.py +140 -0
  358. pinecall/worker/overflow.py +172 -0
  359. pinecall/worker/recordings.py +78 -0
  360. pinecall/worker/router.py +111 -0
  361. pinecall/worker/seat.py +92 -0
  362. pinecall-0.1.0.dist-info/METADATA +237 -0
  363. pinecall-0.1.0.dist-info/RECORD +366 -0
  364. pinecall-0.1.0.dist-info/WHEEL +4 -0
  365. pinecall-0.1.0.dist-info/entry_points.txt +2 -0
  366. pinecall-0.1.0.dist-info/licenses/LICENSE +202 -0
pinecall/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Pinecall: the voice-AI runtime for contact centers. This is the public surface."""
2
+
3
+ from pinecall._exceptions import PinecallError
4
+ from pinecall._version import __version__
5
+
6
+ __all__ = [
7
+ "PinecallError",
8
+ "__version__",
9
+ ]
@@ -0,0 +1,38 @@
1
+ """.env.example, rendered from Settings' declared aliases so the file and the class cannot drift."""
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic.fields import FieldInfo
6
+
7
+ from pinecall._env_files import ENV_FILES
8
+ from pinecall._settings import Settings, variable_of
9
+
10
+ HEADER = f"""\
11
+ # The only .env ever committed, and it is GENERATED: `scripts/generate-env-example` renders it
12
+ # from the aliases src/pinecall/_settings.py declares, and a test fails when the two drift.
13
+ # Copy it to {ENV_FILES[0]} and fill what you have. A real environment variable wins over the file,
14
+ # and a name this runtime does not read is ignored, never an error.
15
+ """
16
+
17
+
18
+ def render_env_example() -> str:
19
+ """Every declared alias, its default, and the one line the class says about it."""
20
+ blocks = [
21
+ f"# {field.description}\n{variable_of(name)}={_default_written_out(field)}"
22
+ for name, field in Settings.model_fields.items()
23
+ ]
24
+ return HEADER + "\n" + "\n\n".join(blocks) + "\n"
25
+
26
+
27
+ def write_env_example(path: Path) -> None:
28
+ """What `scripts/generate-env-example` calls. Idempotent: a second run changes nothing."""
29
+ path.write_text(render_env_example(), encoding="utf-8")
30
+
31
+
32
+ def _default_written_out(field: FieldInfo) -> str:
33
+ """A default as an operator types it: nothing for a key nobody set, true/false for a flag."""
34
+ if field.default is None:
35
+ return ""
36
+ if isinstance(field.default, bool):
37
+ return "true" if field.default else "false"
38
+ return str(field.default)
pinecall/_env_files.py ADDED
@@ -0,0 +1,31 @@
1
+ """Which .env files a process reads: two names, looked for from the working directory upwards."""
2
+
3
+ from collections.abc import Iterator
4
+ from pathlib import Path
5
+
6
+ # The two names a .env is looked for under: in the directory the process started in, then in
7
+ # each parent up to the repository root. `uv run pinecall-runtime …` starts in the runtime
8
+ # directory, where the first name is the file; an app started from a checkout's root, or from an
9
+ # example directory deeper in it, finds the same file under the second. Where the walk stops with
10
+ # both names present, the later wins — pydantic-settings' own order for a list of files.
11
+ ENV_FILES: tuple[str, ...] = (".env", "runtime/.env")
12
+
13
+
14
+ def env_files_read() -> list[Path]:
15
+ """The .env files a Settings built here reads, in the order pydantic reads them."""
16
+ for folder in _folders_up_to_the_repository_root(Path.cwd()):
17
+ found = [folder / name for name in ENV_FILES if (folder / name).is_file()]
18
+ if found:
19
+ return found
20
+ return []
21
+
22
+
23
+ # The walk is BOUNDED on purpose: a stray .env in a directory above the project would be the wrong
24
+ # keys, silently, which is a worse failure than finding none. docs/decisions/settings.md says why.
25
+ def _folders_up_to_the_repository_root(start: Path) -> Iterator[Path]:
26
+ """`start`, then each parent, stopping at the first one holding a `.git` — never above it."""
27
+ folder = start.resolve()
28
+ for candidate in (folder, *folder.parents):
29
+ yield candidate
30
+ if (candidate / ".git").exists():
31
+ return
@@ -0,0 +1,5 @@
1
+ """The root of every error the runtime raises on purpose, so a caller can catch one name."""
2
+
3
+
4
+ class PinecallError(Exception):
5
+ """Catch this to catch anything Pinecall raises deliberately."""
pinecall/_settings.py ADDED
@@ -0,0 +1,387 @@
1
+ """Every environment variable the runtime reads, declared once, for both processes."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Literal, cast, override
6
+
7
+ from pydantic import Field, model_validator
8
+ from pydantic_settings import (
9
+ BaseSettings,
10
+ DotEnvSettingsSource,
11
+ PydanticBaseSettingsSource,
12
+ SettingsConfigDict,
13
+ )
14
+
15
+ from pinecall._env_files import ENV_FILES, env_files_read
16
+ from pinecall._vendor_keys import VendorKeys
17
+
18
+ # Our own knobs carry this prefix; a vendor key keeps the vendor's own name (the alias on the
19
+ # field), so the SDK that reads ANTHROPIC_API_KEY by itself and this class agree.
20
+ ENV_PREFIX = "PINECALL_"
21
+
22
+
23
+ type Role = Literal["all", "hub", "worker"]
24
+
25
+ # Who turns this box's text into vectors. TEI is a container on the box; the other two are an
26
+ # HTTP door across the internet, and the one way to retrieve on a machine TEI has no image for.
27
+ type EmbedProvider = Literal["tei", "perplexity", "openrouter"]
28
+
29
+
30
+ # A lookup never delays a reply past its budget, and a slow model at hang-up never holds the
31
+ # seal: the numbers a session waits on memory and retrieval for, then goes on without them.
32
+ # Declared here, once, because the three fields below take their defaults from it. The two lookup
33
+ # budgets are named for the CHANNEL because each measures a different silence, and the field that
34
+ # reads each one says which (session/lookups.py starts a spoken call's while the caller talks).
35
+ @dataclass(frozen=True)
36
+ class Budgets:
37
+ """What each turn may wait for its lookups, and a hang-up for its memory, before going on."""
38
+
39
+ voice_lookup_ms: int = 250
40
+ text_lookup_ms: int = 3000
41
+ remember_s: float = 8.0
42
+
43
+
44
+ def _names(cls: type[BaseSettings], key: str) -> str:
45
+ """The field an environment name belongs to: its alias, its prefixed name, or itself."""
46
+ for name, field in cls.model_fields.items():
47
+ alias = field.validation_alias or f"{ENV_PREFIX}{name}"
48
+ if key.upper() in {str(alias).upper(), f"{ENV_PREFIX}{name}".upper(), name.upper()}:
49
+ return name
50
+ return key
51
+
52
+
53
+ class Settings(VendorKeys):
54
+ """The environment, typed and frozen. One per process, built by load_settings()."""
55
+
56
+ # A real environment variable WINS over the file: pydantic-settings reads the process
57
+ # environment before the dotenv source. That is what a box with systemd's EnvironmentFile
58
+ # needs, and what a laptop exporting a key from another project will feel — the export
59
+ # shadows the file, and `env | grep PINECALL` is the first thing to run when it surprises.
60
+ # extra="ignore" because the file may carry names this runtime does not read.
61
+ # A box hands its secrets over as systemd credentials: one file per name under the directory
62
+ # named in CREDENTIALS_DIRECTORY, readable by this process alone (`ImportCredential=` in
63
+ # infra/box/*.service). pydantic reads such a directory as a secrets source, matching the
64
+ # names the environment uses, so `/run/credentials/…/DATABASE_URL` is `DATABASE_URL`.
65
+ model_config = SettingsConfigDict(
66
+ env_prefix=ENV_PREFIX,
67
+ env_file=ENV_FILES,
68
+ env_file_encoding="utf-8",
69
+ secrets_dir=os.environ.get("CREDENTIALS_DIRECTORY"), # noqa: TID251 — the one reader
70
+ extra="ignore",
71
+ frozen=True,
72
+ )
73
+
74
+ # `.env.example` writes every optional knob as a bare `NAME=`, which is how a person reads
75
+ # "not set". For a string that is already true; for `PINECALL_MAX_JOBS=` it was not, and a
76
+ # laptop that did nothing but `cp .env.example .env` — the first step of docs/from-zero.md —
77
+ # could not start ANY process: `max_jobs · Input should be a valid integer`. An empty value
78
+ # is an absent one, for every optional field, so the next `int | None` knob cannot repeat it.
79
+ @model_validator(mode="before")
80
+ @classmethod
81
+ def _an_empty_value_is_no_value(cls, given: object) -> object:
82
+ if not isinstance(given, dict):
83
+ return given
84
+ values = cast(dict[str, object], given)
85
+ optional = {
86
+ name
87
+ for name, field in cls.model_fields.items()
88
+ if not field.is_required() and field.default is None
89
+ }
90
+ return {
91
+ key: None if value == "" and _names(cls, key) in optional else value
92
+ for key, value in values.items()
93
+ }
94
+
95
+ # ── LiveKit: the media plane both processes talk to ─────────────────────────
96
+ livekit_url: str = Field(
97
+ default="ws://127.0.0.1:7880",
98
+ validation_alias="LIVEKIT_URL",
99
+ description="LiveKit: the media plane both processes talk to. One port serves ws and http.",
100
+ )
101
+ livekit_api_key: str | None = Field(
102
+ default=None,
103
+ validation_alias="LIVEKIT_API_KEY",
104
+ description="The LiveKit API key, as the LiveKit server's own config declares it.",
105
+ )
106
+ # The pair also signs and verifies a call token: it IS a LiveKit room token, so there is no
107
+ # second secret to set. auth/scopes.py derives one from the dev key when this is unset.
108
+ livekit_api_secret: str | None = Field(
109
+ default=None,
110
+ validation_alias="LIVEKIT_API_SECRET",
111
+ description="Its secret. It also signs the call tokens, so there is no second one.",
112
+ )
113
+ # What POST /v1/tokens tells a browser to connect to. A box talks to its LiveKit on localhost
114
+ # and a browser cannot; unset, the browser is told LIVEKIT_URL, which is right on a laptop.
115
+ livekit_public_url: str | None = Field(
116
+ default=None,
117
+ validation_alias="LIVEKIT_PUBLIC_URL",
118
+ description="The LiveKit URL a browser is told to join. Unset, it hears LIVEKIT_URL.",
119
+ )
120
+ # The box's own carrier account, for the numbers it buys FOR a tenant (POST /v1/numbers/buy):
121
+ # the same three names infra/tools/twilio_trunk.py reads. Unset, the door says so; a tenant's
122
+ # own account is a row of the carriers table and never these.
123
+ twilio_account_sid: str | None = Field(
124
+ default=None,
125
+ validation_alias="TWILIO_ACCOUNT_SID",
126
+ description="The box's Twilio account, for the numbers it buys for a tenant.",
127
+ )
128
+ twilio_api_key: str | None = Field(
129
+ default=None,
130
+ validation_alias="TWILIO_API_KEY",
131
+ description="An API key SID on that account, revocable on its own; else the account SID.",
132
+ )
133
+ twilio_api_secret: str | None = Field(
134
+ default=None,
135
+ validation_alias="TWILIO_API_SECRET",
136
+ description="The API key's secret, or the account's auth token when no key is set.",
137
+ )
138
+ # The box's own public name — what Caddy answers to, and where a carrier sends the INVITE for
139
+ # a number a tenant imports (sip:<domain>:5060). The box already has it in box.env.
140
+ domain: str | None = Field(
141
+ default=None,
142
+ description="The box's public name: where a carrier sends a call. Unset, nothing imports.",
143
+ )
144
+
145
+ # ── The services the doctor asks after: Postgres, and the embedder ─────────
146
+ # Every default is what infra/compose/dev.yml serves, so a fresh clone runs the doctor with
147
+ # no .env at all and every ✗ it prints is a service that is down, never a setting nobody set.
148
+ database_url: str = Field(
149
+ default="postgresql://pinecall:pinecall@127.0.0.1:5432/pinecall",
150
+ validation_alias="DATABASE_URL",
151
+ description="Postgres 17 with pgvector and pg_textsearch: the one stateful service.",
152
+ )
153
+ tei_url: str = Field(
154
+ default="http://127.0.0.1:8081",
155
+ validation_alias="TEI_URL",
156
+ description="TEI, the embedder. 8081, because the gateway serves 8080 on the same host.",
157
+ )
158
+ # Which of the three embeds here, and with what. The model and the door each have a default
159
+ # per provider (providers/embed/__init__.py), so naming the provider alone is a whole
160
+ # configuration; naming the model alone is how a Perplexity box asks for the FLAT model.
161
+ embed_provider: EmbedProvider = Field(
162
+ default="tei",
163
+ validation_alias="EMBED_PROVIDER",
164
+ description="Who embeds: tei · perplexity · openrouter. The other two need their API key.",
165
+ )
166
+ embed_model: str | None = Field(
167
+ default=None,
168
+ validation_alias="EMBED_MODEL",
169
+ description=(
170
+ "The embedding model. Unset: BAAI/bge-m3 · pplx-embed-context-v1-0.6b · "
171
+ "perplexity/pplx-embed-v1-0.6b, by provider."
172
+ ),
173
+ )
174
+ embed_base_url: str | None = Field(
175
+ default=None,
176
+ validation_alias="EMBED_BASE_URL",
177
+ description="Where it is asked. Unset: the provider's own door, and TEI_URL for TEI.",
178
+ )
179
+
180
+ # ── WhatsApp: the two secrets the webhook itself is guarded by ──────────────
181
+ whatsapp_app_secret: str | None = Field(
182
+ default=None,
183
+ description=(
184
+ "The Meta app's App Secret: every webhook body is HMAC-SHA256-signed with it. "
185
+ "Unset, the WhatsApp door is closed."
186
+ ),
187
+ )
188
+ whatsapp_verify_token: str | None = Field(
189
+ default=None,
190
+ description=(
191
+ "The word Meta echoes back when the webhook is subscribed. Choose it; type it in "
192
+ "the Meta app."
193
+ ),
194
+ )
195
+
196
+ # ── Recordings: whether a call's audio is kept at all, and where ───────────
197
+ # RECORD keeps its bare name because it is the one switch an operator flips on a box, and
198
+ # pydantic already reads 0/false/no/off as no. worker/recordings.py composes the path.
199
+ record: bool = Field(
200
+ default=True,
201
+ validation_alias="RECORD",
202
+ description="Whether a call's audio is kept at all. 0, false, no and off all mean no.",
203
+ )
204
+ recordings_root: str = Field(
205
+ default="recordings",
206
+ validation_alias="PINECALL_RECORDINGS",
207
+ description="Where a kept recording lands, absolute or relative to the working directory.",
208
+ )
209
+
210
+ # ── The worker: which gateway it asks, and on whose behalf ──────────────────
211
+ # Read in the JOB process, never the parent: livekit runs a call in a process of its own and
212
+ # hands it the entrypoint by name, so a flag parsed in the parent never reaches it — the
213
+ # environment is the one thing the child inherits.
214
+ gateway_url: str = Field(
215
+ default="http://127.0.0.1:8080",
216
+ description="The gateway a worker's job asks. Read in the job process, never the parent.",
217
+ )
218
+ agent: str | None = Field(
219
+ default=None,
220
+ description="The agent a job that names none is for.",
221
+ )
222
+ # How many calls this worker holds at once, MEASURED on this machine and never guessed: the
223
+ # concurrency at which the p95 first-audio crossed 1.8 s on a ramp, one slot under it for the
224
+ # half-second window livekit re-reads the load in (livekit/agents#4884). Set, the worker
225
+ # reports slots — active over max — and stops taking jobs at 0.7 of them; unset, it reports
226
+ # the machine's CPU, right for a box it shares and wrong for one it has alone. "Slots".
227
+ max_jobs: int | None = Field(
228
+ default=None,
229
+ description="Calls this worker holds at once, measured on its machine. Unset: gate on CPU.",
230
+ )
231
+ # livekit's worker keeps an http health server whose production default is 8081 — TEI's port
232
+ # on this stack — and on `role=all` the two met and the worker died at bind (2026-09-11). So
233
+ # the worker's server has a port of its own, on loopback. docs/decisions/worker.md.
234
+ worker_http_port: int = Field(
235
+ default=8082,
236
+ validation_alias="PINECALL_WORKER_HTTP_PORT",
237
+ description="Where the worker's own health server binds, on loopback. Not 8080 or 8081.",
238
+ )
239
+ # Its name to the hub: what `fleet list` shows and `fleet cordon` names (decisions/fleet.md).
240
+ worker_name: str | None = Field(
241
+ default=None,
242
+ validation_alias="PINECALL_WORKER_NAME",
243
+ description="What this worker is called in its heartbeats. Unset: the short hostname.",
244
+ )
245
+ # What the overflow agent says when every worker is full, then hangs up (worker/overflow.py).
246
+ overflow_says: str = Field(
247
+ default=(
248
+ "En este momento todas nuestras líneas están ocupadas. Hemos tomado nota de su número "
249
+ "y le devolveremos la llamada en cuanto se libere una. Gracias por su paciencia."
250
+ ),
251
+ validation_alias="PINECALL_OVERFLOW_SAYS",
252
+ description="What the overflow agent says when the fleet is full, before it hangs up.",
253
+ )
254
+ # An app socket id as the gateway minted it, which a job puts in the `app` of its call. A
255
+ # developer's own worker sets it so the call is served by the process they typed the command
256
+ # in; a fleet worker on a box sets none and takes the newest holder.
257
+ app: str | None = Field(
258
+ default=None,
259
+ description="The app socket a job's call claims. Unset, the call takes the newest holder.",
260
+ )
261
+
262
+ # ── Ours: the keys and the knobs, each under PINECALL_ ──────────────────────
263
+ # What this box runs, as /etc/pinecall/box.env declares it and infra/box/Makefile enables
264
+ # it: `all` on one machine, `hub` with no worker, `worker` alone dialling a hub. The doctor
265
+ # asks a worker after no Postgres and no embedder, because a worker has neither.
266
+ role: Role = Field(
267
+ default="all",
268
+ description="What this box runs: all · hub · worker. The doctor asks after what it has.",
269
+ )
270
+ # The operator's call and not this runtime's: their box, their people. 0 is no rule at all.
271
+ # What stops a guess is argon2id at rest and five tries a minute, never the floor.
272
+ min_password: int = Field(
273
+ default=8, ge=0, description="How short a member's password may be. 0 is no rule."
274
+ )
275
+ ops_key: str | None = Field(
276
+ default=None,
277
+ description="The key /v1/ops/* is authenticated by. Unset, the operator API is closed.",
278
+ )
279
+ cloud: bool = Field(default=False, description="Pinecall's hosted gateway: a plan, billed.")
280
+ # Whether a stranger may make an org here. Its own flag and not `cloud`, because they are two
281
+ # facts: a box somebody runs for their own agents wants no sign-up at all — which is why this
282
+ # is OFF unless the person who runs the gateway turns it on — and a cloud may close sign-ups
283
+ # without ceasing to be one. The console draws the way in only where this says so.
284
+ signup: bool = Field(
285
+ default=False,
286
+ description="Whether a stranger may make an org at this gateway. Off unless you say.",
287
+ )
288
+ # Packages installed beside the runtime that plug a policy into its named points: how a box
289
+ # that charges says the numbers without the runtime learning what a plan is. extensions/.
290
+ extensions: str = Field(
291
+ default="",
292
+ description="Packages that plug a policy into the runtime's points, comma separated.",
293
+ )
294
+ # The org's own key, as `keys issue` printed it: what the WORKER knocks at its gateway with,
295
+ # minted once by pinecall-worker-key.service and kept in the credstore.
296
+ #
297
+ # It was PINECALL_API_KEY, and that name was three different things at once: this credential,
298
+ # a key source in the v2 CLI, and the variable v1's SDK exports — so a laptop with v1's export
299
+ # still live silently registered agents into whatever org THAT key named. The runtime's half
300
+ # of the collision is gone by having a name of its own; the CLI's half goes with the profiles.
301
+ worker_key: str | None = Field(
302
+ default=None,
303
+ description="The org key the worker knocks its gateway with, as `keys issue` printed it.",
304
+ )
305
+ # The one secret that guards other people's secrets: a Fernet key, generated once on the box,
306
+ # under which every tenant's own provider key is encrypted at rest — here and never in the
307
+ # database, so a stolen dump is not a stolen tenant. Unset, every call runs on the box's keys.
308
+ vault_key: str | None = Field(
309
+ default=None,
310
+ description=(
311
+ "A Fernet key, generated once on the box by `pinecall-runtime box secrets`: a tenant's "
312
+ "own provider keys are encrypted under it. Unset, the provider-key doors answer 503."
313
+ ),
314
+ )
315
+ log_level: str = Field(
316
+ default="INFO",
317
+ description="How much both processes say: DEBUG, INFO, WARNING or ERROR.",
318
+ )
319
+ # What judging ONE call at hang-up may cost. Zero closes the door on every judge that would
320
+ # ask a model; the policies that answer by code still answer. docs/decisions/scoring.md.
321
+ judge_ceiling_eur: float = Field(
322
+ default=0.002,
323
+ description="What judging one call may spend on a model, in euros. Zero: no judge asks.",
324
+ )
325
+
326
+ # ── Memory and retrieval: what a turn and a hang-up wait for ──────────────
327
+ # The language BM25 ranks in is the index's own, fixed in 0008 and 0009 (`spanish`): a
328
+ # migration reads no setting, so there is none to read here either.
329
+ voice_lookup_budget_ms: int = Field(
330
+ default=Budgets.voice_lookup_ms,
331
+ description=(
332
+ "What a spoken turn waits for the recall and search it started while the caller was "
333
+ "still talking, in ms. It is silence on the line, so it is small."
334
+ ),
335
+ )
336
+ text_lookup_budget_ms: int = Field(
337
+ default=Budgets.text_lookup_ms,
338
+ description=(
339
+ "What a written turn waits for recall and search, in ms. A written caller sends a "
340
+ "whole message, so the lookup only starts at the end — and nobody hears the wait."
341
+ ),
342
+ )
343
+ remember_budget_s: float = Field(
344
+ default=Budgets.remember_s,
345
+ description="What a hang-up waits for memory to be written, in s. Past it the call seals.",
346
+ )
347
+
348
+ @property
349
+ def budgets(self) -> Budgets:
350
+ """The three budgets as one thing a session is handed."""
351
+ return Budgets(
352
+ voice_lookup_ms=self.voice_lookup_budget_ms,
353
+ text_lookup_ms=self.text_lookup_budget_ms,
354
+ remember_s=self.remember_budget_s,
355
+ )
356
+
357
+ # pydantic resolves an env_file NAME against the working directory alone, so it is the one part
358
+ # of the config that cannot express the walk: the dotenv source is rebuilt here over the
359
+ # absolute paths env_files_read() found, and everything else — the prefix, the encoding, its
360
+ # place AFTER the process environment — is still model_config's. With no env_file the default
361
+ # source is handed back untouched, which is how tests/conftest.py keeps the suite off your file.
362
+ @override
363
+ @classmethod
364
+ def settings_customise_sources(
365
+ cls,
366
+ settings_cls: type[BaseSettings],
367
+ init_settings: PydanticBaseSettingsSource,
368
+ env_settings: PydanticBaseSettingsSource,
369
+ dotenv_settings: PydanticBaseSettingsSource,
370
+ file_secret_settings: PydanticBaseSettingsSource,
371
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
372
+ """The dotenv source reads the files the walk found, not two names under the cwd."""
373
+ if settings_cls.model_config.get("env_file") is None:
374
+ return init_settings, env_settings, dotenv_settings, file_secret_settings
375
+ walked = DotEnvSettingsSource(settings_cls, env_file=env_files_read())
376
+ return init_settings, env_settings, walked, file_secret_settings
377
+
378
+
379
+ def load_settings() -> Settings:
380
+ """Read the environment now. Cheap, and no hidden global: hold the result where it is needed."""
381
+ return Settings()
382
+
383
+
384
+ def variable_of(field: str) -> str:
385
+ """The environment variable one settings field reads: its own alias, or PINECALL_ + its name."""
386
+ alias = Settings.model_fields[field].validation_alias
387
+ return alias if isinstance(alias, str) else f"{ENV_PREFIX}{field.upper()}"