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