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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""The providers doors: the catalogue, the org's own vendor keys, a vendor's voices, a sample."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Query, Response
|
|
7
|
+
|
|
8
|
+
from pinecall.domain.agent import Voice
|
|
9
|
+
from pinecall.domain.errors import DeclarationRefused, NotFound, TooManyRequests
|
|
10
|
+
from pinecall.domain.names import Credentials
|
|
11
|
+
from pinecall.gateway._call_setup import keys_of
|
|
12
|
+
from pinecall.gateway._deps import GatewayDep, PipelineKey, ProvidersKey, ScopeDep
|
|
13
|
+
from pinecall.providers import build, catalog, credentials, voices
|
|
14
|
+
from pinecall.providers.build import Modality, primary
|
|
15
|
+
from pinecall.providers.catalog import Providers
|
|
16
|
+
from pinecall.providers.credentials import Keyring
|
|
17
|
+
from pinecall.providers.declared import model_of
|
|
18
|
+
from pinecall.tenancy import vault
|
|
19
|
+
from pinecall.wire.rest.providers import (
|
|
20
|
+
Availability,
|
|
21
|
+
Catalogue,
|
|
22
|
+
ListedVoice,
|
|
23
|
+
ProviderKeyRequest,
|
|
24
|
+
ProviderRow,
|
|
25
|
+
Standing,
|
|
26
|
+
VendorsResponse,
|
|
27
|
+
VoiceSampleRequest,
|
|
28
|
+
VoicesListed,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
router = APIRouter()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
WAV = "audio/wav"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Samples cost vendor time and write no usage row, so a key gets this many a minute.
|
|
38
|
+
SAMPLES_A_MINUTE = 30
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Long enough for a greeting, short enough to keep the vendor's cost to seconds.
|
|
42
|
+
LONGEST_SAMPLE = 400
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
TOO_LONG = "a sample says a sentence: {length} characters, and the ceiling is {ceiling}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
TOO_MANY = "this key asked for its {count} samples this minute: the ceiling is {count} a minute"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
NO_CREDENTIALS = "a key, or the credentials object the vendor's plugin takes, and not both"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
NO_SUCH_KEY = "this org brought no key for {vendor}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# What a voice says when the picker names no line and the providers row has none in the language.
|
|
58
|
+
A_LINE = "Hello, this is the voice you are listening to."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Availabilities a call can run on.
|
|
62
|
+
READY = frozenset({"yours", "offered"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# v1's word for each, which the console draws: a vendor nobody keyed for this org has "no key".
|
|
66
|
+
STANDING: dict[Availability, Standing] = {
|
|
67
|
+
"yours": "ready",
|
|
68
|
+
"offered": "ready",
|
|
69
|
+
"bring your own": "no key",
|
|
70
|
+
"broken": "no plugin",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# The same for every key of an org: what is installed, and whose key would run each vendor.
|
|
75
|
+
@router.get("/v1/providers")
|
|
76
|
+
async def catalogue(_key: ProvidersKey, scope: ScopeDep, gateway: GatewayDep) -> Catalogue:
|
|
77
|
+
"""Every vendor this build runs and how this org may run it, the defaults, the models named."""
|
|
78
|
+
pool = gateway.connections.pool
|
|
79
|
+
configured = await catalog.providers(pool)
|
|
80
|
+
return catalogue_of(configured, await keys_of(pool, gateway.connections.vault, scope))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Vendor names only, never a key or a prefix of one.
|
|
84
|
+
@router.get("/v1/provider-keys")
|
|
85
|
+
async def own_vendors(key: ProvidersKey, gateway: GatewayDep) -> VendorsResponse:
|
|
86
|
+
"""The vendors the org brought its own credentials for."""
|
|
87
|
+
return VendorsResponse(vendors=await vault.vendors_of(gateway.connections.pool, key.org))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.put("/v1/provider-keys/{vendor}", status_code=204)
|
|
91
|
+
async def bring_key(
|
|
92
|
+
vendor: str, body: ProviderKeyRequest, key: ProvidersKey, gateway: GatewayDep
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Keep the org's own credentials for a vendor; its calls run on them from the next one."""
|
|
95
|
+
named = installed_vendor(vendor)
|
|
96
|
+
credentials = credentials_of(body)
|
|
97
|
+
build.check_credentials(named, credentials)
|
|
98
|
+
connections = gateway.connections
|
|
99
|
+
await vault.put_credentials(connections.pool, connections.vault, key.org, named, credentials)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.delete("/v1/provider-keys/{vendor}", status_code=204)
|
|
103
|
+
async def take_key_back(vendor: str, key: ProvidersKey, gateway: GatewayDep) -> None:
|
|
104
|
+
"""Forget the org's credentials for a vendor; its calls run on the box's from the next one."""
|
|
105
|
+
named = installed_vendor(vendor)
|
|
106
|
+
if not await vault.drop_credentials(gateway.connections.pool, key.org, named):
|
|
107
|
+
raise NotFound(NO_SUCH_KEY.format(vendor=named))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Built as a call builds the stage: the operator's options, on the key a call would use.
|
|
111
|
+
@router.get("/v1/voices")
|
|
112
|
+
async def list_voices(
|
|
113
|
+
_key: PipelineKey,
|
|
114
|
+
scope: ScopeDep,
|
|
115
|
+
gateway: GatewayDep,
|
|
116
|
+
tts: Annotated[str, Query(min_length=1)],
|
|
117
|
+
language: Annotated[str | None, Query()] = None,
|
|
118
|
+
) -> VoicesListed:
|
|
119
|
+
"""The voices the providers row lists for a vendor, else its plugin's; neither is a 404."""
|
|
120
|
+
pool = gateway.connections.pool
|
|
121
|
+
configured = await catalog.providers(pool)
|
|
122
|
+
named = installed_vendor(tts)
|
|
123
|
+
spoken = primary(language)
|
|
124
|
+
listed = voices.kept(configured, named, spoken)
|
|
125
|
+
if not listed and not _lists_voices(named, ("tts",)):
|
|
126
|
+
raise NotFound(voices.NOT_LISTED.format(vendor=named))
|
|
127
|
+
if not listed:
|
|
128
|
+
keyring = await keys_of(pool, gateway.connections.vault, scope)
|
|
129
|
+
stage = credentials.stage("tts", Voice(provider=named), configured, keyring)
|
|
130
|
+
listed = await voices.voices(dataclasses.replace(stage, language=spoken))
|
|
131
|
+
return VoicesListed(
|
|
132
|
+
tts=tts,
|
|
133
|
+
language=language,
|
|
134
|
+
voices=[
|
|
135
|
+
voice
|
|
136
|
+
for item in listed
|
|
137
|
+
if (voice := _listed_voice(item)) is not None
|
|
138
|
+
and (spoken is None or not voice.language or voice.language.startswith(spoken))
|
|
139
|
+
],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# The three words are read exactly as the settings door reads them, so what plays is what saves.
|
|
144
|
+
# The answer is the WAV itself, its latencies in Server-Timing, which a browser shows.
|
|
145
|
+
@router.post("/v1/voices/sample")
|
|
146
|
+
async def voice_sample(
|
|
147
|
+
body: VoiceSampleRequest, key: PipelineKey, scope: ScopeDep, gateway: GatewayDep
|
|
148
|
+
) -> Response:
|
|
149
|
+
"""The line said by that vendor's voice, over the path a call speaks on."""
|
|
150
|
+
if not await gateway.samples.allowed(key.bearer.key.key_id):
|
|
151
|
+
raise TooManyRequests(TOO_MANY.format(count=gateway.samples.tries))
|
|
152
|
+
pool = gateway.connections.pool
|
|
153
|
+
configured = await catalog.providers(pool)
|
|
154
|
+
language = primary(body.language)
|
|
155
|
+
text = body.text or configured.lines.get(language or "", A_LINE)
|
|
156
|
+
if len(text) > LONGEST_SAMPLE:
|
|
157
|
+
raise DeclarationRefused(TOO_LONG.format(length=len(text), ceiling=LONGEST_SAMPLE))
|
|
158
|
+
named = model_of(body.tts, "tts", in_use=configured.defaults["tts"].vendor)
|
|
159
|
+
voice = Voice(
|
|
160
|
+
provider=configured.defaults["tts"].vendor if named is None else named.provider,
|
|
161
|
+
model=body.model or (None if named is None else named.model or None),
|
|
162
|
+
voice_id=body.voice,
|
|
163
|
+
)
|
|
164
|
+
keyring = await keys_of(pool, gateway.connections.vault, scope)
|
|
165
|
+
stage = credentials.stage("tts", voice, configured, keyring)
|
|
166
|
+
heard = await voices.sample(
|
|
167
|
+
dataclasses.replace(stage, voice=voice.voice_id, language=language), text
|
|
168
|
+
)
|
|
169
|
+
timing = f"first-audio;dur={heard.first_audio_ms}, total;dur={heard.total_ms}"
|
|
170
|
+
return Response(content=heard.wav, media_type=WAV, headers={"Server-Timing": timing})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def catalogue_of(configured: Providers, keyring: Keyring) -> Catalogue:
|
|
174
|
+
"""Every installed vendor as this org may run it, with the row's defaults and models."""
|
|
175
|
+
return Catalogue(
|
|
176
|
+
providers=[
|
|
177
|
+
ProviderRow(
|
|
178
|
+
name=vendor.name,
|
|
179
|
+
does=list(vendor.does),
|
|
180
|
+
aliases=[],
|
|
181
|
+
note=vendor.broken or "",
|
|
182
|
+
standing=STANDING[vendor.availability],
|
|
183
|
+
ready=vendor.availability in READY,
|
|
184
|
+
env=None,
|
|
185
|
+
extra=vendor.name,
|
|
186
|
+
voices_listed=_lists_voices(vendor.name, vendor.does)
|
|
187
|
+
or voices.lists_any(configured, vendor.name),
|
|
188
|
+
availability=vendor.availability,
|
|
189
|
+
broken=vendor.broken,
|
|
190
|
+
)
|
|
191
|
+
for vendor in credentials.readiness(build.installed(), keyring)
|
|
192
|
+
],
|
|
193
|
+
defaults={modality: stage.vendor for modality, stage in configured.defaults.items()},
|
|
194
|
+
voices=[],
|
|
195
|
+
models={named: [model] for named, model in configured.models.items()},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def installed_vendor(vendor: str) -> str:
|
|
200
|
+
"""The vendor's name as installed; refused naming the ones this build runs."""
|
|
201
|
+
named = vendor.strip().lower()
|
|
202
|
+
if named not in build.installed():
|
|
203
|
+
known = ", ".join(sorted(build.installed()))
|
|
204
|
+
raise DeclarationRefused(f"no vendor named {vendor!r}; this build runs: {known}")
|
|
205
|
+
return named
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def credentials_of(body: ProviderKeyRequest) -> Credentials:
|
|
209
|
+
"""One key, or the object the plugin takes, out of the body; refused when neither or both."""
|
|
210
|
+
if (body.key is None) == (body.credentials is None):
|
|
211
|
+
raise DeclarationRefused(NO_CREDENTIALS)
|
|
212
|
+
if body.key is not None:
|
|
213
|
+
if not body.key.strip():
|
|
214
|
+
raise DeclarationRefused(NO_CREDENTIALS)
|
|
215
|
+
return body.key.strip()
|
|
216
|
+
return body.credentials or {}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# A plugin lists its voices when its TTS class has a `list_voices`; nothing is built to ask.
|
|
220
|
+
def _lists_voices(vendor: str, does: tuple[Modality, ...]) -> bool:
|
|
221
|
+
if "tts" not in does:
|
|
222
|
+
return False
|
|
223
|
+
try:
|
|
224
|
+
speech = getattr(build.plugin(vendor), "TTS", None)
|
|
225
|
+
except DeclarationRefused:
|
|
226
|
+
return False
|
|
227
|
+
return callable(getattr(speech, "list_voices", None))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _listed_voice(item: voices.ListedVoice) -> ListedVoice | None:
|
|
231
|
+
detail = item.detail
|
|
232
|
+
return ListedVoice(
|
|
233
|
+
id=item.id,
|
|
234
|
+
name=item.name,
|
|
235
|
+
language=str(detail.get("language") or ""),
|
|
236
|
+
description=str(detail.get("description") or ""),
|
|
237
|
+
gender=str(detail.get("gender") or ""),
|
|
238
|
+
country=str(detail.get("country") or ""),
|
|
239
|
+
accent=str(detail.get("accent") or ""),
|
|
240
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""A call's recording as a player reads it: plain, or sealed and opened a range at a time."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Header
|
|
8
|
+
from fastapi.responses import FileResponse, StreamingResponse
|
|
9
|
+
|
|
10
|
+
from pinecall.domain.errors import NotFound
|
|
11
|
+
from pinecall.gateway import _deps
|
|
12
|
+
from pinecall.gateway._deps import CallReaderDep, GatewayDep
|
|
13
|
+
from pinecall.gateway._gateway import Gateway
|
|
14
|
+
from pinecall.log import queries
|
|
15
|
+
from pinecall.process.recordings import (
|
|
16
|
+
SEALED_FILE,
|
|
17
|
+
Fetched,
|
|
18
|
+
kept_on_disk,
|
|
19
|
+
recordings_of,
|
|
20
|
+
served_sealed,
|
|
21
|
+
)
|
|
22
|
+
from pinecall.process.sealed_audio import on_disk
|
|
23
|
+
from pinecall.tenancy import recording_keys
|
|
24
|
+
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
NOT_RECORDED = "call {call} kept no recording"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
NOT_HERE = "the recording of {call} is at {path} on the box that took the call, not on this one"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# From the bucket once the worker moved it there, else the file the recorder wrote on this disk;
|
|
35
|
+
# a sealed one is opened by the call's own key, the range asked for and no more.
|
|
36
|
+
@router.get("/v1/calls/{call}/recording", response_model=None)
|
|
37
|
+
async def recording(
|
|
38
|
+
call: str,
|
|
39
|
+
reading: CallReaderDep,
|
|
40
|
+
gateway: GatewayDep,
|
|
41
|
+
byte_range: Annotated[str | None, Header(alias="range")] = None,
|
|
42
|
+
) -> FileResponse | StreamingResponse:
|
|
43
|
+
"""The call's audio, with byte ranges so a player can seek."""
|
|
44
|
+
await _deps.check_readable(gateway, reading, call)
|
|
45
|
+
await _deps.record_read(gateway, reading, call, "recording")
|
|
46
|
+
state = await gateway.logs.reading(call).snapshot()
|
|
47
|
+
summary = next(
|
|
48
|
+
(
|
|
49
|
+
item
|
|
50
|
+
for item in reversed(await gateway.logs.store.whole(call))
|
|
51
|
+
if item.type == "call.summary"
|
|
52
|
+
),
|
|
53
|
+
None,
|
|
54
|
+
)
|
|
55
|
+
pointer = None if summary is None else summary.data.get("recording")
|
|
56
|
+
if state.seq == 0 or not isinstance(pointer, str) or not pointer:
|
|
57
|
+
raise NotFound(NOT_RECORDED.format(call=call))
|
|
58
|
+
root = Path(gateway.connections.settings.recordings_root)
|
|
59
|
+
path = await asyncio.to_thread(kept_on_disk, root, call, pointer)
|
|
60
|
+
if path is None:
|
|
61
|
+
raise NotFound(NOT_RECORDED.format(call=call))
|
|
62
|
+
kept = await queries.scope_of_call(gateway.connections.pool, call)
|
|
63
|
+
if kept is not None and kept.scope is not None:
|
|
64
|
+
fetched = await _fetched(gateway, kept.scope.org, call, path, byte_range)
|
|
65
|
+
if fetched is not None:
|
|
66
|
+
headers = {
|
|
67
|
+
**fetched.headers,
|
|
68
|
+
"content-disposition": f'attachment; filename="{call}.ogg"',
|
|
69
|
+
}
|
|
70
|
+
return StreamingResponse(
|
|
71
|
+
fetched.body, status_code=fetched.status, headers=headers, media_type="audio/ogg"
|
|
72
|
+
)
|
|
73
|
+
if path.name == SEALED_FILE or not await asyncio.to_thread(path.is_file):
|
|
74
|
+
raise NotFound(NOT_HERE.format(call=call, path=pointer))
|
|
75
|
+
return FileResponse(path, media_type="audio/ogg", filename=f"{call}.ogg")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# A sealed recording is in the bucket, or on this disk where its upload failed; a plain one is
|
|
79
|
+
# the bucket's to answer, and a file on this disk is served as a file.
|
|
80
|
+
async def _fetched(
|
|
81
|
+
gateway: Gateway, org: str, call: str, path: Path, byte_range: str | None
|
|
82
|
+
) -> Fetched | None:
|
|
83
|
+
connections = gateway.connections
|
|
84
|
+
stored = recordings_of(connections.settings, connections.http)
|
|
85
|
+
if path.name != SEALED_FILE:
|
|
86
|
+
return await stored.fetch(org, call, byte_range)
|
|
87
|
+
key = await recording_keys.key_of(connections.pool, connections.vault, call)
|
|
88
|
+
kept = await stored.sealed(org, call) or await on_disk(call, path)
|
|
89
|
+
return None if key is None or kept is None else served_sealed(kept, key, byte_range)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""The console's asks relayed to a running app: chat, knowledge, memory, the view, evals."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Annotated, get_args
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, Query
|
|
9
|
+
|
|
10
|
+
from pinecall.domain.errors import (
|
|
11
|
+
AppRefused,
|
|
12
|
+
Conflict,
|
|
13
|
+
NotFound,
|
|
14
|
+
)
|
|
15
|
+
from pinecall.domain.names import JsonObject
|
|
16
|
+
from pinecall.domain.scope import Scope
|
|
17
|
+
from pinecall.gateway import _deps
|
|
18
|
+
from pinecall.gateway._deps import Acting, CallsKey, GatewayDep
|
|
19
|
+
from pinecall.gateway._gateway import Gateway
|
|
20
|
+
from pinecall.gateway._sockets import (
|
|
21
|
+
NO_AGENT,
|
|
22
|
+
NO_DEV_ANSWERER,
|
|
23
|
+
NO_UNCLAIMED,
|
|
24
|
+
NOT_THAT_APP,
|
|
25
|
+
Registration,
|
|
26
|
+
)
|
|
27
|
+
from pinecall.tenancy import keys
|
|
28
|
+
from pinecall.wire.events import DevRequest
|
|
29
|
+
from pinecall.wire.parts import DevVerb
|
|
30
|
+
|
|
31
|
+
router = APIRouter()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
NO_SUCH_VERB = "no dev verb {verb} in {family}: the verbs are {verbs}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
NO_ANSWER = "the app holding agent {slug} did not answer {verb} within {seconds:.0f}s"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
APP_LEFT = "the app holding agent {slug} disconnected before it answered {verb}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Longer than any verb takes: a suite waits 20 s on the app's side.
|
|
44
|
+
ANSWERED_WITHIN_S = 120.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
NAMED_FAMILIES: dict[str, frozenset[str]] = {
|
|
48
|
+
"chat": frozenset({"chat.roster", "chat.start", "chat.say", "chat.end"}),
|
|
49
|
+
"knowledge": frozenset({"knowledge.roster", "knowledge.push", "knowledge.eval"}),
|
|
50
|
+
"memory": frozenset({"memory.roster", "memory.eval", "memory.extraction"}),
|
|
51
|
+
"view": frozenset({"view.render"}),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
EVERY_VERB: frozenset[str] = frozenset(get_args(DevVerb.__value__))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# FastAPI builds it: the body, and the socket the query names.
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Ask:
|
|
61
|
+
"""What a console asks an app, and which of the agent's sockets it asks."""
|
|
62
|
+
|
|
63
|
+
said: JsonObject
|
|
64
|
+
app: Annotated[str | None, Query()] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Relay:
|
|
69
|
+
"""Which family's verb, of which agent."""
|
|
70
|
+
|
|
71
|
+
family: str
|
|
72
|
+
slug: str
|
|
73
|
+
verb: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Every verb no other family names is an eval's.
|
|
77
|
+
FAMILIES: dict[str, frozenset[str]] = {
|
|
78
|
+
**NAMED_FAMILIES,
|
|
79
|
+
"evals": EVERY_VERB - frozenset().union(*NAMED_FAMILIES.values()),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
AskDep = Annotated[Ask, Depends(Ask)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.post("/v1/agents/{slug}/dev/chat/{verb}")
|
|
87
|
+
async def relay_chat(
|
|
88
|
+
slug: str, verb: str, ask: AskDep, key: _deps.TalkKey, gateway: GatewayDep
|
|
89
|
+
) -> JsonObject:
|
|
90
|
+
"""A chat verb, answered by the process that answers the console for the agent."""
|
|
91
|
+
return await _relayed(gateway, key, Relay("chat", slug, verb), ask)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@router.post("/v1/agents/{slug}/dev/knowledge/{verb}")
|
|
95
|
+
async def relay_knowledge(
|
|
96
|
+
slug: str, verb: str, ask: AskDep, key: _deps.KnowledgeKey, gateway: GatewayDep
|
|
97
|
+
) -> JsonObject:
|
|
98
|
+
"""A knowledge verb, answered by the process that answers the console for the agent."""
|
|
99
|
+
return await _relayed(gateway, key, Relay("knowledge", slug, verb), ask)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.post("/v1/agents/{slug}/dev/memory/{verb}")
|
|
103
|
+
async def relay_memory(
|
|
104
|
+
slug: str, verb: str, ask: AskDep, key: _deps.MemoryKey, gateway: GatewayDep
|
|
105
|
+
) -> JsonObject:
|
|
106
|
+
"""A memory verb, answered by the process that answers the console for the agent."""
|
|
107
|
+
return await _relayed(gateway, key, Relay("memory", slug, verb), ask)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.post("/v1/agents/{slug}/dev/view/{verb}")
|
|
111
|
+
async def relay_view(
|
|
112
|
+
slug: str, verb: str, ask: AskDep, key: CallsKey, gateway: GatewayDep
|
|
113
|
+
) -> JsonObject:
|
|
114
|
+
"""The side panel beside a conversation, rendered by the app serving the call."""
|
|
115
|
+
return await _relayed(gateway, key, Relay("view", slug, verb), ask)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.post("/v1/agents/{slug}/dev/evals/{verb}")
|
|
119
|
+
async def relay_evals(
|
|
120
|
+
slug: str, verb: str, ask: AskDep, key: _deps.EvalsKey, gateway: GatewayDep
|
|
121
|
+
) -> JsonObject:
|
|
122
|
+
"""An evals verb, answered by the process that answers the console for the agent."""
|
|
123
|
+
return await _relayed(gateway, key, Relay("evals", slug, verb), ask)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# dev.request goes down the socket unstored; the app's refusal comes back as its own status.
|
|
127
|
+
async def _relayed(gateway: Gateway, key: Acting, relay: Relay, params: Ask) -> JsonObject:
|
|
128
|
+
if relay.verb not in FAMILIES[relay.family]:
|
|
129
|
+
verbs = sorted(FAMILIES[relay.family])
|
|
130
|
+
raise NotFound(NO_SUCH_VERB.format(verb=relay.verb, family=relay.family, verbs=verbs))
|
|
131
|
+
registration = _answering(gateway, keys.scope_of(key.bearer, key.env), relay, params.app)
|
|
132
|
+
ask_id = f"dev_{uuid4().hex[:12]}"
|
|
133
|
+
request = DevRequest.model_validate({"id": ask_id, "verb": relay.verb, "data": params.said})
|
|
134
|
+
waiting = await gateway.live.ask(ask_id)
|
|
135
|
+
if not await gateway.live.tell(
|
|
136
|
+
registration.owner, _deps.ephemeral_entry(relay.slug, request, kind="dev.request")
|
|
137
|
+
):
|
|
138
|
+
gateway.live.pending_answers.pop(ask_id, None)
|
|
139
|
+
waiting.cancel()
|
|
140
|
+
raise AppRefused(502, APP_LEFT.format(slug=relay.slug, verb=relay.verb))
|
|
141
|
+
try:
|
|
142
|
+
answer = await asyncio.wait_for(waiting, ANSWERED_WITHIN_S)
|
|
143
|
+
except TimeoutError:
|
|
144
|
+
gateway.live.pending_answers.pop(ask_id, None)
|
|
145
|
+
waiting.cancel()
|
|
146
|
+
said_late = NO_ANSWER.format(slug=relay.slug, verb=relay.verb, seconds=ANSWERED_WITHIN_S)
|
|
147
|
+
raise AppRefused(504, said_late) from None
|
|
148
|
+
if answer.refused is not None:
|
|
149
|
+
raise AppRefused(answer.refused.status, answer.refused.detail)
|
|
150
|
+
return answer.result or {}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# The panel is drawn by the class serving the call; every other verb by the CLI beside it.
|
|
154
|
+
def _answering(gateway: Gateway, where: Scope, relay: Relay, app: str | None) -> Registration:
|
|
155
|
+
sockets = gateway.sockets
|
|
156
|
+
if app is not None:
|
|
157
|
+
named = sockets.serving(where, relay.slug, app)
|
|
158
|
+
if named is None:
|
|
159
|
+
raise Conflict(NOT_THAT_APP.format(app=app, slug=relay.slug))
|
|
160
|
+
return named
|
|
161
|
+
if relay.family == "view":
|
|
162
|
+
found, refusal = sockets.serving(where, relay.slug, None), NO_UNCLAIMED
|
|
163
|
+
else:
|
|
164
|
+
found, refusal = sockets.answering_dev(where, relay.slug), NO_DEV_ANSWERER
|
|
165
|
+
if found is not None:
|
|
166
|
+
return found
|
|
167
|
+
if sockets.of(where, relay.slug) is not None:
|
|
168
|
+
raise Conflict(refusal.format(slug=relay.slug))
|
|
169
|
+
raise NotFound(NO_AGENT.format(slug=relay.slug))
|