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.
- pinecall/__init__.py +9 -0
- pinecall/_env_example.py +38 -0
- pinecall/_env_files.py +31 -0
- pinecall/_exceptions.py +5 -0
- pinecall/_settings.py +387 -0
- pinecall/_vendor_keys.py +283 -0
- pinecall/_version.py +4 -0
- pinecall/api/__init__.py +1 -0
- pinecall/api/_deps.py +384 -0
- pinecall/api/_live.py +246 -0
- pinecall/api/_operator.py +50 -0
- pinecall/api/_refusals.py +40 -0
- pinecall/api/_serving.py +49 -0
- pinecall/api/agents/__init__.py +1 -0
- pinecall/api/agents/dev.py +177 -0
- pinecall/api/agents/doors.py +136 -0
- pinecall/api/agents/endpoints.py +164 -0
- pinecall/api/agents/handlers.py +120 -0
- pinecall/api/agents/holding.py +71 -0
- pinecall/api/agents/on_a_call.py +146 -0
- pinecall/api/agents/provider_keys.py +28 -0
- pinecall/api/agents/registry.py +399 -0
- pinecall/api/agents/socket.py +240 -0
- pinecall/api/app.py +312 -0
- pinecall/api/calls/__init__.py +1 -0
- pinecall/api/calls/chat.py +236 -0
- pinecall/api/calls/commands.py +52 -0
- pinecall/api/calls/events.py +395 -0
- pinecall/api/calls/listing.py +86 -0
- pinecall/api/calls/lookup.py +45 -0
- pinecall/api/calls/opening.py +98 -0
- pinecall/api/calls/recording.py +46 -0
- pinecall/api/calls/sink.py +281 -0
- pinecall/api/calls/state.py +48 -0
- pinecall/api/calls/tools.py +85 -0
- pinecall/api/contacts.py +124 -0
- pinecall/api/discovery.py +41 -0
- pinecall/api/evals/__init__.py +1 -0
- pinecall/api/evals/attachment.py +83 -0
- pinecall/api/evals/caller.py +31 -0
- pinecall/api/evals/conversation.py +179 -0
- pinecall/api/evals/listening.py +93 -0
- pinecall/api/evals/replay.py +75 -0
- pinecall/api/evals/runner.py +329 -0
- pinecall/api/evals/runs.py +124 -0
- pinecall/api/evals/scoring.py +128 -0
- pinecall/api/evals/settling.py +43 -0
- pinecall/api/evals/spoken.py +96 -0
- pinecall/api/evals/voice.py +100 -0
- pinecall/api/extraction.py +97 -0
- pinecall/api/fleet.py +157 -0
- pinecall/api/floor.py +51 -0
- pinecall/api/keys.py +117 -0
- pinecall/api/knowledge.py +136 -0
- pinecall/api/listen.py +24 -0
- pinecall/api/login.py +196 -0
- pinecall/api/managed.py +100 -0
- pinecall/api/members.py +266 -0
- pinecall/api/numbers.py +362 -0
- pinecall/api/orgs.py +274 -0
- pinecall/api/pages.py +84 -0
- pinecall/api/pairing.py +88 -0
- pinecall/api/pipeline.py +56 -0
- pinecall/api/pipeline_report.py +168 -0
- pinecall/api/provider_keys.py +99 -0
- pinecall/api/providers.py +95 -0
- pinecall/api/routes.py +114 -0
- pinecall/api/signup.py +115 -0
- pinecall/api/supervise/__init__.py +1 -0
- pinecall/api/supervise/aiming.py +148 -0
- pinecall/api/supervise/verbs.py +51 -0
- pinecall/api/supervise_seat.py +28 -0
- pinecall/api/tokens.py +184 -0
- pinecall/api/usage.py +103 -0
- pinecall/api/whatsapp/__init__.py +1 -0
- pinecall/api/whatsapp/doors.py +38 -0
- pinecall/api/whatsapp/threads.py +236 -0
- pinecall/api/whatsapp/webhook.py +119 -0
- pinecall/api/whoami.py +100 -0
- pinecall/auth/__init__.py +21 -0
- pinecall/auth/bearer.py +30 -0
- pinecall/auth/codes.py +62 -0
- pinecall/auth/invitations.py +30 -0
- pinecall/auth/keys.py +378 -0
- pinecall/auth/members.py +390 -0
- pinecall/auth/pairing.py +115 -0
- pinecall/auth/passwords.py +37 -0
- pinecall/auth/scopes.py +240 -0
- pinecall/auth/throttle.py +43 -0
- pinecall/cli/__init__.py +87 -0
- pinecall/cli/box/__init__.py +5 -0
- pinecall/cli/box/verbs.py +136 -0
- pinecall/cli/columns.py +12 -0
- pinecall/cli/doctor/__init__.py +5 -0
- pinecall/cli/doctor/probes.py +58 -0
- pinecall/cli/doctor/verbs.py +339 -0
- pinecall/cli/fleet/__init__.py +5 -0
- pinecall/cli/fleet/verbs.py +206 -0
- pinecall/cli/gateway.py +36 -0
- pinecall/cli/init.py +106 -0
- pinecall/cli/keys/__init__.py +5 -0
- pinecall/cli/keys/verbs.py +193 -0
- pinecall/cli/migrate.py +103 -0
- pinecall/cli/operator.py +125 -0
- pinecall/cli/orgs/__init__.py +5 -0
- pinecall/cli/orgs/verbs.py +340 -0
- pinecall/cli/providers/__init__.py +5 -0
- pinecall/cli/providers/verbs.py +61 -0
- pinecall/cli/routes/__init__.py +5 -0
- pinecall/cli/routes/verbs.py +187 -0
- pinecall/cli/sessions/__init__.py +5 -0
- pinecall/cli/sessions/render.py +163 -0
- pinecall/cli/sessions/source.py +55 -0
- pinecall/cli/sessions/verbs.py +266 -0
- pinecall/cli/worker.py +148 -0
- pinecall/evals/__init__.py +76 -0
- pinecall/evals/answers.py +39 -0
- pinecall/evals/bridge.py +186 -0
- pinecall/evals/caller.py +151 -0
- pinecall/evals/calling.py +298 -0
- pinecall/evals/case.py +117 -0
- pinecall/evals/checks/__init__.py +1 -0
- pinecall/evals/checks/consent.py +48 -0
- pinecall/evals/checks/errors.py +24 -0
- pinecall/evals/checks/latency.py +41 -0
- pinecall/evals/checks/register.py +43 -0
- pinecall/evals/checks/replayed.py +90 -0
- pinecall/evals/checks/verdict.py +44 -0
- pinecall/evals/goldens.py +79 -0
- pinecall/evals/headless.py +104 -0
- pinecall/evals/judges/__init__.py +1 -0
- pinecall/evals/judges/asking.py +86 -0
- pinecall/evals/judges/consent.py +53 -0
- pinecall/evals/judges/expected.py +201 -0
- pinecall/evals/judges/grounded.py +237 -0
- pinecall/evals/judges/model.py +77 -0
- pinecall/evals/judges/policy.py +53 -0
- pinecall/evals/judges/register.py +68 -0
- pinecall/evals/line.py +89 -0
- pinecall/evals/matrix.py +144 -0
- pinecall/evals/polling.py +22 -0
- pinecall/evals/remembering.py +33 -0
- pinecall/evals/report.py +120 -0
- pinecall/evals/runs.py +179 -0
- pinecall/evals/score.py +160 -0
- pinecall/evals/speech.py +94 -0
- pinecall/evals/transcript.py +39 -0
- pinecall/evals/verdicts.py +75 -0
- pinecall/extensions/__init__.py +6 -0
- pinecall/extensions/loading.py +43 -0
- pinecall/extensions/points.py +28 -0
- pinecall/fleet/__init__.py +37 -0
- pinecall/fleet/clouds.py +88 -0
- pinecall/fleet/decisions.py +168 -0
- pinecall/fleet/loop.py +104 -0
- pinecall/fleet/roster.py +151 -0
- pinecall/gateway/admin/assets/index-C3WMXdfL.css +1 -0
- pinecall/gateway/admin/assets/index-DR1mxoAh.js +58 -0
- pinecall/gateway/admin/index.html +20 -0
- pinecall/gateway/console/assets/index-DCTnou52.css +1 -0
- pinecall/gateway/console/assets/index-dOcE4usU.js +99 -0
- pinecall/gateway/console/index.html +20 -0
- pinecall/gateway/console/logo-mark.png +0 -0
- pinecall/knowledge/__init__.py +6 -0
- pinecall/knowledge/chunking.py +111 -0
- pinecall/knowledge/protocol.py +69 -0
- pinecall/knowledge/scoring.py +87 -0
- pinecall/knowledge/store.py +267 -0
- pinecall/log/__init__.py +33 -0
- pinecall/log/entry.py +38 -0
- pinecall/log/fanout.py +108 -0
- pinecall/log/filters.py +67 -0
- pinecall/log/latencies.py +67 -0
- pinecall/log/logs.py +139 -0
- pinecall/log/pii.py +133 -0
- pinecall/log/projection.py +244 -0
- pinecall/log/reduce.py +396 -0
- pinecall/log/replay.py +166 -0
- pinecall/log/room.py +69 -0
- pinecall/log/snapshots.py +43 -0
- pinecall/log/store/__init__.py +21 -0
- pinecall/log/store/memory.py +145 -0
- pinecall/log/store/migrating.py +205 -0
- pinecall/log/store/pool.py +26 -0
- pinecall/log/store/postgres.py +373 -0
- pinecall/log/store/protocol.py +94 -0
- pinecall/log/usage.py +134 -0
- pinecall/log/wording.py +25 -0
- pinecall/log/writers.py +126 -0
- pinecall/lookups/__init__.py +5 -0
- pinecall/lookups/answers.py +44 -0
- pinecall/lookups/entries.py +69 -0
- pinecall/lookups/service.py +242 -0
- pinecall/memory/__init__.py +11 -0
- pinecall/memory/extraction.py +231 -0
- pinecall/memory/goldens.py +226 -0
- pinecall/memory/pgvector.py +324 -0
- pinecall/memory/protocol.py +105 -0
- pinecall/memory/ranking.py +48 -0
- pinecall/memory/scoring.py +99 -0
- pinecall/migrations/0001_call_log.sql +62 -0
- pinecall/migrations/0002_api_keys.sql +16 -0
- pinecall/migrations/0003_routes.sql +19 -0
- pinecall/migrations/0004_eval_runs.sql +22 -0
- pinecall/migrations/0005_tokens.sql +20 -0
- pinecall/migrations/0006_orgs.sql +64 -0
- pinecall/migrations/0007_provider_keys.sql +20 -0
- pinecall/migrations/0008_memory.sql +46 -0
- pinecall/migrations/0009_knowledge.sql +49 -0
- pinecall/migrations/0010_memory_model.sql +14 -0
- pinecall/migrations/0011_feature_quotas.sql +14 -0
- pinecall/migrations/0012_pipeline_overrides.sql +26 -0
- pinecall/migrations/0013_environments.sql +39 -0
- pinecall/migrations/0014_members.sql +38 -0
- pinecall/migrations/0015_carriers.sql +20 -0
- pinecall/migrations/0016_managed_numbers.sql +11 -0
- pinecall/migrations/0017_provider_scope.sql +16 -0
- pinecall/migrations/0018_worlds_in_the_data.sql +50 -0
- pinecall/migrations/0019_seats.sql +13 -0
- pinecall/migrations/0020_operators.sql +18 -0
- pinecall/migrations/0021_a_developers_own.sql +48 -0
- pinecall/migrations/0022_a_tts_knob.sql +17 -0
- pinecall/migrations/0023_sandbox.sql +42 -0
- pinecall/migrations/migrations.lock +11 -0
- pinecall/orgs/__init__.py +1 -0
- pinecall/orgs/admission.py +135 -0
- pinecall/orgs/carriers.py +144 -0
- pinecall/orgs/meter.py +46 -0
- pinecall/orgs/table.py +198 -0
- pinecall/orgs/turned.py +62 -0
- pinecall/orgs/vault.py +154 -0
- pinecall/providers/__init__.py +1 -0
- pinecall/providers/_inference.py +51 -0
- pinecall/providers/blocks.py +79 -0
- pinecall/providers/catalog.py +303 -0
- pinecall/providers/declaration.py +231 -0
- pinecall/providers/embed/__init__.py +109 -0
- pinecall/providers/embed/perplexity.py +193 -0
- pinecall/providers/embed/tei.py +100 -0
- pinecall/providers/embed/wire.py +63 -0
- pinecall/providers/embedder.py +89 -0
- pinecall/providers/knocks.py +39 -0
- pinecall/providers/llm/__init__.py +5 -0
- pinecall/providers/llm/anthropic.py +20 -0
- pinecall/providers/llm/livekit.py +14 -0
- pinecall/providers/llm/openai.py +18 -0
- pinecall/providers/models.py +59 -0
- pinecall/providers/overrides.py +233 -0
- pinecall/providers/pipeline.py +108 -0
- pinecall/providers/plugin.py +150 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/published.py +71 -0
- pinecall/providers/published_prices.json +5344 -0
- pinecall/providers/registry.py +193 -0
- pinecall/providers/standing.py +40 -0
- pinecall/providers/stt/__init__.py +20 -0
- pinecall/providers/stt/deepgram.py +26 -0
- pinecall/providers/stt/livekit.py +23 -0
- pinecall/providers/stt/soniox.py +38 -0
- pinecall/providers/tts/__init__.py +5 -0
- pinecall/providers/tts/elevenlabs.py +57 -0
- pinecall/providers/tts/livekit.py +26 -0
- pinecall/providers/tts/voices.py +113 -0
- pinecall/providers/usage.py +40 -0
- pinecall/routes/__init__.py +1 -0
- pinecall/routes/answering.py +89 -0
- pinecall/routes/table.py +253 -0
- pinecall/routes/trunks.py +160 -0
- pinecall/routes/twilio.py +236 -0
- pinecall/session/__init__.py +1 -0
- pinecall/session/asking.py +71 -0
- pinecall/session/clock.py +54 -0
- pinecall/session/declaring.py +57 -0
- pinecall/session/first_entries.py +41 -0
- pinecall/session/greeting.py +33 -0
- pinecall/session/knowing.py +22 -0
- pinecall/session/lookups.py +284 -0
- pinecall/session/pending.py +72 -0
- pinecall/session/remembering.py +48 -0
- pinecall/session/scoring.py +21 -0
- pinecall/session/supervising.py +39 -0
- pinecall/session/text/__init__.py +1 -0
- pinecall/session/text/agent.py +148 -0
- pinecall/session/text/measure.py +92 -0
- pinecall/session/text/running.py +45 -0
- pinecall/session/text/session.py +328 -0
- pinecall/session/text/supervising.py +94 -0
- pinecall/session/text/turns.py +144 -0
- pinecall/session/visibility.py +46 -0
- pinecall/session/voice/__init__.py +5 -0
- pinecall/session/voice/agent.py +124 -0
- pinecall/session/voice/barge_in.py +63 -0
- pinecall/session/voice/commands.py +252 -0
- pinecall/session/voice/dead_end.py +23 -0
- pinecall/session/voice/events.py +257 -0
- pinecall/session/voice/hanging_up.py +75 -0
- pinecall/session/voice/hearing.py +59 -0
- pinecall/session/voice/kit.py +24 -0
- pinecall/session/voice/metrics.py +161 -0
- pinecall/session/voice/platform.py +44 -0
- pinecall/session/voice/reading_back.py +21 -0
- pinecall/session/voice/room/__init__.py +7 -0
- pinecall/session/voice/room/datachannel.py +248 -0
- pinecall/session/voice/room/facts.py +210 -0
- pinecall/session/voice/room/holding.py +50 -0
- pinecall/session/voice/room/invite.py +40 -0
- pinecall/session/voice/room/mute.py +50 -0
- pinecall/session/voice/room/remove.py +21 -0
- pinecall/session/voice/room/send.py +22 -0
- pinecall/session/voice/session.py +173 -0
- pinecall/session/voice/sip.py +83 -0
- pinecall/session/voice/supervising.py +139 -0
- pinecall/session/voice/tools.py +114 -0
- pinecall/session/voice/transfer.py +74 -0
- pinecall/session/voice/voice.py +391 -0
- pinecall/session/voice/writing.py +70 -0
- pinecall/tokens/__init__.py +1 -0
- pinecall/tokens/ledger.py +107 -0
- pinecall/tokens/room.py +49 -0
- pinecall/tokens/seating.py +62 -0
- pinecall/tokens/spending.py +37 -0
- pinecall/types/__init__.py +152 -0
- pinecall/types/agent.py +157 -0
- pinecall/types/call.py +93 -0
- pinecall/types/carrier.py +88 -0
- pinecall/types/channel.py +19 -0
- pinecall/types/consent.py +128 -0
- pinecall/types/counting.py +11 -0
- pinecall/types/dispatch.py +28 -0
- pinecall/types/fusion.py +32 -0
- pinecall/types/goldens.py +60 -0
- pinecall/types/json.py +6 -0
- pinecall/types/key.py +120 -0
- pinecall/types/knowledge.py +90 -0
- pinecall/types/lookup.py +102 -0
- pinecall/types/member.py +100 -0
- pinecall/types/org.py +117 -0
- pinecall/types/prompt.py +99 -0
- pinecall/types/provider_keys.py +15 -0
- pinecall/types/refused.py +8 -0
- pinecall/types/route.py +68 -0
- pinecall/types/token.py +139 -0
- pinecall/types/tool.py +74 -0
- pinecall/whatsapp/__init__.py +1 -0
- pinecall/whatsapp/graph.py +97 -0
- pinecall/whatsapp/inbound.py +130 -0
- pinecall/whatsapp/meta.py +9 -0
- pinecall/whatsapp/routing.py +44 -0
- pinecall/whatsapp/sending.py +47 -0
- pinecall/whatsapp/signing.py +23 -0
- pinecall/worker/__init__.py +22 -0
- pinecall/worker/client.py +227 -0
- pinecall/worker/commanding.py +56 -0
- pinecall/worker/entry.py +200 -0
- pinecall/worker/heartbeat.py +91 -0
- pinecall/worker/load.py +120 -0
- pinecall/worker/main.py +140 -0
- pinecall/worker/overflow.py +172 -0
- pinecall/worker/recordings.py +78 -0
- pinecall/worker/router.py +111 -0
- pinecall/worker/seat.py +92 -0
- pinecall-0.1.0.dist-info/METADATA +237 -0
- pinecall-0.1.0.dist-info/RECORD +366 -0
- pinecall-0.1.0.dist-info/WHEEL +4 -0
- pinecall-0.1.0.dist-info/entry_points.txt +2 -0
- pinecall-0.1.0.dist-info/licenses/LICENSE +202 -0
pinecall/__init__.py
ADDED
pinecall/_env_example.py
ADDED
|
@@ -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
|
pinecall/_exceptions.py
ADDED
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()}"
|