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,400 @@
|
|
|
1
|
+
"""The JWTs the gateway signs: a room token and its dispatch, a seat, a log token, a code token."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
import jwt
|
|
11
|
+
from livekit.api import AccessToken, VideoGrants
|
|
12
|
+
from livekit.protocol.room import RoomConfiguration
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
14
|
+
|
|
15
|
+
from pinecall.domain.names import Env
|
|
16
|
+
from pinecall.domain.person import RoomScope
|
|
17
|
+
from pinecall.domain.scope import SCOPE_ATTRIBUTE, Scope
|
|
18
|
+
from pinecall.postgres.pool import Pool
|
|
19
|
+
from pinecall.tenancy.keys import (
|
|
20
|
+
A_SEAT,
|
|
21
|
+
A_SEAT_LASTS_S,
|
|
22
|
+
A_VISITOR,
|
|
23
|
+
AGENT_ATTRIBUTE,
|
|
24
|
+
BOUND_TO_ONE_CALL,
|
|
25
|
+
CODE_ATTRIBUTE,
|
|
26
|
+
ENV_ATTRIBUTE,
|
|
27
|
+
JWT_PARTS,
|
|
28
|
+
MINTED,
|
|
29
|
+
NAME_ATTRIBUTE,
|
|
30
|
+
PROJECTION_ATTRIBUTE,
|
|
31
|
+
SUBJECT_ATTRIBUTE,
|
|
32
|
+
THE_MICROPHONE,
|
|
33
|
+
Bearer,
|
|
34
|
+
)
|
|
35
|
+
from pinecall.wire.parts import Projection
|
|
36
|
+
|
|
37
|
+
# One statement on every open: the update is the spend, atomic (of two dispatches racing, one
|
|
38
|
+
# updates the row), and the join says what the ledger held before it. A token is spent only by the
|
|
39
|
+
# org, the world and the agent it was minted for; one minted before its world was kept (no env) is
|
|
40
|
+
# held to its org and agent.
|
|
41
|
+
SPEND = """
|
|
42
|
+
WITH spent AS (
|
|
43
|
+
UPDATE tokens SET spent_at = now()
|
|
44
|
+
WHERE call = %(call)s AND spent_at IS NULL AND org = %(org)s AND agent = %(agent)s
|
|
45
|
+
AND (env IS NULL OR env = %(env)s)
|
|
46
|
+
RETURNING call
|
|
47
|
+
)
|
|
48
|
+
SELECT (SELECT count(*) FROM spent) AS spent, tokens.call AS minted, tokens.spent_at
|
|
49
|
+
FROM (SELECT 1) AS probe LEFT JOIN tokens ON tokens.call = %(call)s
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
IDENTITY_BYTES = 6
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
type Spending = Literal["spent", "already_spent", "never_minted", "minted_elsewhere"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
ONE_VISIT_TTL_S = 60
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# The header of a token the gateway signs with its own key; a LiveKit token names no key id.
|
|
63
|
+
OUR_KEY_ID = "pinecall"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Longer than any call, short enough that a copied link dies the same day.
|
|
67
|
+
READ_TTL_S = 4 * 60 * 60
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Signer:
|
|
72
|
+
"""The LiveKit pair a room token is signed with, and the gateway's own key for every other."""
|
|
73
|
+
|
|
74
|
+
api_key: str
|
|
75
|
+
secret: str
|
|
76
|
+
# A log token and a code token open no room, so LiveKit never reads them: they are signed with
|
|
77
|
+
# this key, which no worker holds, and the pair every worker holds forges none.
|
|
78
|
+
own: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Visitor:
|
|
83
|
+
"""Who a room token seats, until when, and the dispatch it carries."""
|
|
84
|
+
|
|
85
|
+
expires_at: float
|
|
86
|
+
identity: str | None = None
|
|
87
|
+
subject: str | None = None
|
|
88
|
+
name: str | None = None
|
|
89
|
+
# Signed inside the token, so the browser cannot change the agent, org or world it reaches.
|
|
90
|
+
dispatch: RoomConfiguration | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Visit:
|
|
95
|
+
"""What a checked room token says."""
|
|
96
|
+
|
|
97
|
+
call: str
|
|
98
|
+
scope: RoomScope
|
|
99
|
+
expires_at: float
|
|
100
|
+
identity: str | None = None
|
|
101
|
+
subject: str | None = None
|
|
102
|
+
name: str | None = None
|
|
103
|
+
projection: Projection | None = None
|
|
104
|
+
# A code token names its code, agent and world instead of a call; `call` is "" for it.
|
|
105
|
+
code: str | None = None
|
|
106
|
+
agent: str | None = None
|
|
107
|
+
env: Env | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _Room(BaseModel):
|
|
111
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
112
|
+
|
|
113
|
+
room: str = ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _Answer(BaseModel):
|
|
117
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
118
|
+
|
|
119
|
+
scope: RoomScope = Field(alias=SCOPE_ATTRIBUTE)
|
|
120
|
+
subject: str | None = Field(None, alias=SUBJECT_ATTRIBUTE)
|
|
121
|
+
name: str | None = Field(None, alias=NAME_ATTRIBUTE)
|
|
122
|
+
projection: Projection | None = Field(None, alias=PROJECTION_ATTRIBUTE)
|
|
123
|
+
code: str | None = Field(None, alias=CODE_ATTRIBUTE)
|
|
124
|
+
agent: str | None = Field(None, alias=AGENT_ATTRIBUTE)
|
|
125
|
+
env: Env | None = Field(None, alias=ENV_ATTRIBUTE)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# livekit's claims, the ones a token of ours carries; its verifier drops `exp`.
|
|
129
|
+
class _Claims(BaseModel):
|
|
130
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
131
|
+
|
|
132
|
+
exp: float
|
|
133
|
+
sub: str | None = None
|
|
134
|
+
video: _Room = _Room()
|
|
135
|
+
attributes: _Answer
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class SupervisorSeat:
|
|
140
|
+
"""A person's seat in a live call: the token and who it says they are."""
|
|
141
|
+
|
|
142
|
+
call: str
|
|
143
|
+
identity: str
|
|
144
|
+
token: str
|
|
145
|
+
subject: str | None
|
|
146
|
+
name: str | None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class MintedToken:
|
|
151
|
+
"""A room token as the ledger keeps it: one call, spent once."""
|
|
152
|
+
|
|
153
|
+
call: str
|
|
154
|
+
org: str
|
|
155
|
+
env: Env
|
|
156
|
+
agent: str
|
|
157
|
+
scope: RoomScope
|
|
158
|
+
expires_at: float
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True, kw_only=True)
|
|
162
|
+
class Grant:
|
|
163
|
+
"""What a room token of one scope may do."""
|
|
164
|
+
|
|
165
|
+
connects: bool
|
|
166
|
+
audio: bool
|
|
167
|
+
reads_log: bool
|
|
168
|
+
own_call_only: bool
|
|
169
|
+
ttl_s: int | None
|
|
170
|
+
# `audio` publishes and subscribes; `hears` only subscribes; `hidden` is not seen in the room;
|
|
171
|
+
# `writes` sends on the call's data channel, which the agent reads as the caller's words.
|
|
172
|
+
hears: bool = False
|
|
173
|
+
hidden: bool = False
|
|
174
|
+
writes: bool = False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
GRANTS: Mapping[RoomScope, Grant] = {
|
|
178
|
+
# It reads its own call's log too, so a browser holds one token.
|
|
179
|
+
"talk": Grant(
|
|
180
|
+
connects=True,
|
|
181
|
+
audio=True,
|
|
182
|
+
writes=True,
|
|
183
|
+
reads_log=True,
|
|
184
|
+
own_call_only=True,
|
|
185
|
+
ttl_s=ONE_VISIT_TTL_S,
|
|
186
|
+
),
|
|
187
|
+
# It subscribes: livekit delivers its text streams to subscribers only.
|
|
188
|
+
"chat": Grant(
|
|
189
|
+
connects=True,
|
|
190
|
+
audio=False,
|
|
191
|
+
hears=True,
|
|
192
|
+
writes=True,
|
|
193
|
+
reads_log=True,
|
|
194
|
+
own_call_only=True,
|
|
195
|
+
ttl_s=ONE_VISIT_TTL_S,
|
|
196
|
+
),
|
|
197
|
+
"observe": Grant(
|
|
198
|
+
connects=False,
|
|
199
|
+
audio=False,
|
|
200
|
+
hears=True,
|
|
201
|
+
hidden=True,
|
|
202
|
+
reads_log=True,
|
|
203
|
+
own_call_only=False,
|
|
204
|
+
ttl_s=None,
|
|
205
|
+
),
|
|
206
|
+
# Not hidden: livekit delivers no track of a hidden seat, so a takeover would be silent.
|
|
207
|
+
"supervise": Grant(
|
|
208
|
+
connects=False,
|
|
209
|
+
audio=True,
|
|
210
|
+
reads_log=True,
|
|
211
|
+
own_call_only=False,
|
|
212
|
+
ttl_s=None,
|
|
213
|
+
),
|
|
214
|
+
"read": Grant(
|
|
215
|
+
connects=False,
|
|
216
|
+
audio=False,
|
|
217
|
+
reads_log=True,
|
|
218
|
+
own_call_only=True,
|
|
219
|
+
ttl_s=READ_TTL_S,
|
|
220
|
+
),
|
|
221
|
+
"participate": Grant(
|
|
222
|
+
connects=False,
|
|
223
|
+
audio=False,
|
|
224
|
+
writes=True,
|
|
225
|
+
reads_log=True,
|
|
226
|
+
own_call_only=True,
|
|
227
|
+
ttl_s=None,
|
|
228
|
+
),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# From the credential, never from a parameter: a scope that reads beyond its own call is the
|
|
233
|
+
# tenant's; an API key is the tenant.
|
|
234
|
+
PROJECTION_OF: Mapping[RoomScope, Projection] = {
|
|
235
|
+
scope: "tenant" if grant.reads_log and not grant.own_call_only else "public"
|
|
236
|
+
for scope, grant in GRANTS.items()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# What POST /v1/tokens may mint: the scopes that join a room.
|
|
241
|
+
MINTED_FOR_A_VISIT: frozenset[RoomScope] = frozenset(
|
|
242
|
+
scope for scope, grant in GRANTS.items() if grant.connects
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def is_a_jwt(bearer: str) -> bool:
|
|
247
|
+
"""Whether the bearer is shaped like a token, not a key: its shape, not its signature."""
|
|
248
|
+
parts = bearer.split(".")
|
|
249
|
+
return len(parts) == JWT_PARTS and all(parts)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def room_token(signer: Signer, call: str, scope: RoomScope, visitor: Visitor) -> str:
|
|
253
|
+
"""A token that seats the visitor in the call's room with the scope's grants."""
|
|
254
|
+
grant = GRANTS[scope]
|
|
255
|
+
grants = VideoGrants(
|
|
256
|
+
room=call,
|
|
257
|
+
room_join=True,
|
|
258
|
+
can_publish=grant.audio,
|
|
259
|
+
can_subscribe=grant.audio or grant.hears,
|
|
260
|
+
# A widget writes to its call over the data channel; one that watches or listens does not.
|
|
261
|
+
can_publish_data=grant.writes,
|
|
262
|
+
can_publish_sources=[THE_MICROPHONE] if grant.audio else [],
|
|
263
|
+
hidden=grant.hidden,
|
|
264
|
+
)
|
|
265
|
+
who = {
|
|
266
|
+
attribute: value
|
|
267
|
+
for attribute, value in (
|
|
268
|
+
(SUBJECT_ATTRIBUTE, visitor.subject),
|
|
269
|
+
(NAME_ATTRIBUTE, visitor.name),
|
|
270
|
+
)
|
|
271
|
+
if value
|
|
272
|
+
}
|
|
273
|
+
token = (
|
|
274
|
+
AccessToken(signer.api_key, signer.secret)
|
|
275
|
+
.with_identity(visitor.identity or _identity(A_VISITOR))
|
|
276
|
+
.with_grants(grants)
|
|
277
|
+
# livekit takes a lifetime and stamps the expiry itself.
|
|
278
|
+
.with_ttl(timedelta(seconds=visitor.expires_at - time.time()))
|
|
279
|
+
.with_attributes({**who, SCOPE_ATTRIBUTE: scope})
|
|
280
|
+
)
|
|
281
|
+
if visitor.dispatch is not None:
|
|
282
|
+
token = token.with_room_config(visitor.dispatch)
|
|
283
|
+
return token.to_jwt()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def seat(signer: Signer, call: str, scope: RoomScope, bearer: Bearer) -> SupervisorSeat:
|
|
287
|
+
"""A seat for the key's person in a live call, to listen or to supervise."""
|
|
288
|
+
identity = _identity(A_SEAT)
|
|
289
|
+
name = bearer.member.name if bearer.member is not None else bearer.key.name
|
|
290
|
+
visitor = Visitor(
|
|
291
|
+
expires_at=time.time() + A_SEAT_LASTS_S,
|
|
292
|
+
identity=identity,
|
|
293
|
+
subject=bearer.key.subject,
|
|
294
|
+
name=name,
|
|
295
|
+
)
|
|
296
|
+
token = room_token(signer, call, scope, visitor)
|
|
297
|
+
return SupervisorSeat(
|
|
298
|
+
call=call, identity=identity, token=token, subject=bearer.key.subject, name=name
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# It names the call's room without the right to join it: livekit refuses it, and our doors read
|
|
303
|
+
# it as the call's.
|
|
304
|
+
def log_token(signer: Signer, call: str, projection: Projection) -> str:
|
|
305
|
+
"""A token that reads one call's log and recording, through the projection named."""
|
|
306
|
+
attributes = {SCOPE_ATTRIBUTE: "read", PROJECTION_ATTRIBUTE: projection}
|
|
307
|
+
return _unjoinable(signer, call, READ_TTL_S, attributes)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def code_token(signer: Signer, code: str, agent: str, world: Env, expires_at: float) -> str:
|
|
311
|
+
"""A token that asks after one caller code until the code dies; it opens no room."""
|
|
312
|
+
attributes = {
|
|
313
|
+
SCOPE_ATTRIBUTE: "read",
|
|
314
|
+
CODE_ATTRIBUTE: code,
|
|
315
|
+
AGENT_ATTRIBUTE: agent,
|
|
316
|
+
ENV_ATTRIBUTE: world,
|
|
317
|
+
}
|
|
318
|
+
return _unjoinable(signer, f"code:{code}", expires_at - time.time(), attributes)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def read(signer: Signer, token: str) -> Visit | None:
|
|
322
|
+
"""What a token of ours says; None for anything else, whatever the reason."""
|
|
323
|
+
try:
|
|
324
|
+
ours = jwt.get_unverified_header(token).get("kid") == OUR_KEY_ID
|
|
325
|
+
decoded = jwt.decode(
|
|
326
|
+
token,
|
|
327
|
+
signer.own if ours else signer.secret,
|
|
328
|
+
algorithms=["HS256"],
|
|
329
|
+
options={"require": ["exp"]},
|
|
330
|
+
leeway=0,
|
|
331
|
+
)
|
|
332
|
+
claims = _Claims.model_validate(decoded)
|
|
333
|
+
except (jwt.PyJWTError, ValidationError):
|
|
334
|
+
return None
|
|
335
|
+
data = claims.attributes
|
|
336
|
+
# A livekit token minted for another purpose with the same pair reads nothing here, and a
|
|
337
|
+
# listener hears the room but reads no log. A reading token is the gateway's own key's alone:
|
|
338
|
+
# one in its shape under the pair is a worker's forgery.
|
|
339
|
+
if (
|
|
340
|
+
not claims.video.room
|
|
341
|
+
or data.scope not in BOUND_TO_ONE_CALL
|
|
342
|
+
or ours != (data.scope == "read")
|
|
343
|
+
):
|
|
344
|
+
return None
|
|
345
|
+
return Visit(
|
|
346
|
+
call="" if data.code is not None else claims.video.room,
|
|
347
|
+
scope=data.scope,
|
|
348
|
+
expires_at=claims.exp,
|
|
349
|
+
identity=claims.sub,
|
|
350
|
+
subject=data.subject,
|
|
351
|
+
name=data.name,
|
|
352
|
+
projection=data.projection,
|
|
353
|
+
code=data.code,
|
|
354
|
+
agent=data.agent,
|
|
355
|
+
env=data.env,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
async def minted(pool: Pool, token: MintedToken) -> None:
|
|
360
|
+
"""Keep a room token the ledger will spend once, when its call opens."""
|
|
361
|
+
values = {
|
|
362
|
+
"call": token.call,
|
|
363
|
+
"org": token.org,
|
|
364
|
+
"env": token.env,
|
|
365
|
+
"agent": token.agent,
|
|
366
|
+
"scope": token.scope,
|
|
367
|
+
"expires_at": token.expires_at,
|
|
368
|
+
}
|
|
369
|
+
async with pool.connection() as connection:
|
|
370
|
+
await connection.execute(MINTED, values)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def spend(pool: Pool, call: str, scope: Scope, agent: str) -> Spending:
|
|
374
|
+
"""Spend the call's token in the scope the worker names, once; otherwise say why not."""
|
|
375
|
+
wanted = {"call": call, "org": scope.org, "env": scope.env, "agent": agent}
|
|
376
|
+
async with pool.connection() as connection:
|
|
377
|
+
row = await (await connection.execute(SPEND, wanted)).fetchone()
|
|
378
|
+
if row is not None and row["spent"]:
|
|
379
|
+
return "spent"
|
|
380
|
+
if row is None or row["minted"] is None:
|
|
381
|
+
return "never_minted"
|
|
382
|
+
return "minted_elsewhere" if row["spent_at"] is None else "already_spent"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# The shape LiveKit gives a token (sub, exp, video, attributes), so one reader reads both; signed
|
|
386
|
+
# with the gateway's own key under its key id, and joining no room.
|
|
387
|
+
def _unjoinable(signer: Signer, room: str, ttl_s: float, attributes: dict[str, str]) -> str:
|
|
388
|
+
now = int(time.time())
|
|
389
|
+
claims = {
|
|
390
|
+
"sub": _identity(A_VISITOR),
|
|
391
|
+
"iat": now,
|
|
392
|
+
"exp": now + int(ttl_s),
|
|
393
|
+
"video": {"room": room, "roomJoin": False},
|
|
394
|
+
"attributes": attributes,
|
|
395
|
+
}
|
|
396
|
+
return jwt.encode(claims, signer.own, algorithm="HS256", headers={"kid": OUR_KEY_ID})
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _identity(prefix: str) -> str:
|
|
400
|
+
return f"{prefix}{secrets.token_hex(IDENTITY_BYTES)}"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""A number's calls for a carrier's traceback: the calls kept, the calls erased, every dial."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from psycopg.rows import DictRow
|
|
7
|
+
|
|
8
|
+
from pinecall.domain.names import parse_e164, parse_env
|
|
9
|
+
from pinecall.domain.scope import Scope
|
|
10
|
+
from pinecall.postgres.pool import Pool
|
|
11
|
+
from pinecall.tenancy import reads
|
|
12
|
+
from pinecall.tenancy.reads import Read
|
|
13
|
+
from pinecall.tenancy.retention import RECORDS_KEPT_S
|
|
14
|
+
from pinecall.wire.rest.ops import Traceback, TracebackCall, TracebackDial
|
|
15
|
+
|
|
16
|
+
# A kept call's facts, then an erased call's record: the same columns, oldest first. A call that
|
|
17
|
+
# never started (a dial nobody answered) is placed by when it ended.
|
|
18
|
+
CALLS = """
|
|
19
|
+
SELECT call, org, env, direction, from_number, to_number, started_at, ended_at, end_reason, erased
|
|
20
|
+
FROM (
|
|
21
|
+
SELECT head.call, head.org, head.env, facts.direction, facts.from_number, facts.to_number,
|
|
22
|
+
head.started_at, facts.ended_at, facts.end_reason, false AS erased
|
|
23
|
+
FROM call_facts facts JOIN call_log_head head ON head.call = facts.call
|
|
24
|
+
WHERE facts.channel = 'phone'
|
|
25
|
+
AND (facts.from_number = %(number)s OR facts.to_number = %(number)s)
|
|
26
|
+
UNION ALL
|
|
27
|
+
SELECT call, org, env, direction, from_number, to_number, started_at, ended_at, end_reason, true
|
|
28
|
+
FROM call_records WHERE from_number = %(number)s OR to_number = %(number)s
|
|
29
|
+
) found
|
|
30
|
+
WHERE coalesce(started_at, ended_at, 0) >= %(since)s
|
|
31
|
+
ORDER BY coalesce(started_at, ended_at, 0)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# Every dial to the number, placed or refused, with who asked.
|
|
35
|
+
DIALS = """
|
|
36
|
+
SELECT org, env, agent, call, shown, asked_by, refused, at FROM dials
|
|
37
|
+
WHERE dialled = %(number)s AND at >= to_timestamp(%(since)s)
|
|
38
|
+
ORDER BY at
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def of_number(pool: Pool, number: str, since: float | None = None) -> Traceback:
|
|
43
|
+
"""Every phone call with the number and every dial to it, since an epoch second or 24 months."""
|
|
44
|
+
kept = parse_e164(number)
|
|
45
|
+
start = time.time() - RECORDS_KEPT_S if since is None else since
|
|
46
|
+
params = {"number": kept, "since": start}
|
|
47
|
+
# independent: the calls and the dials of a number, read apart
|
|
48
|
+
async with pool.connection() as connection:
|
|
49
|
+
calls = await (await connection.execute(CALLS, params)).fetchall()
|
|
50
|
+
dials = await (await connection.execute(DIALS, params)).fetchall()
|
|
51
|
+
return Traceback(
|
|
52
|
+
number=kept,
|
|
53
|
+
since=start,
|
|
54
|
+
calls=[TracebackCall.model_validate(row) for row in calls],
|
|
55
|
+
dials=[_dial(row) for row in dials],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def read_by(pool: Pool, found: Traceback, reader: str) -> None:
|
|
60
|
+
"""Write the lookup into the access log of every org whose calls or dials it showed."""
|
|
61
|
+
seen = {(call.org, call.env) for call in found.calls if call.org is not None}
|
|
62
|
+
seen |= {(dial.org, dial.env) for dial in found.dials}
|
|
63
|
+
for org, env in sorted(seen, key=str):
|
|
64
|
+
scope = Scope(org, parse_env(env) if env else "production")
|
|
65
|
+
await reads.record(pool, scope, Read(found.number, "traceback", reader))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _dial(row: DictRow) -> TracebackDial:
|
|
69
|
+
at: datetime = row["at"]
|
|
70
|
+
return TracebackDial.model_validate({**row, "at": at.timestamp()})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""What an org used in a world, month by month: the totals admission reads, and their refold."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import UTC, date, datetime
|
|
5
|
+
|
|
6
|
+
from psycopg.rows import DictRow
|
|
7
|
+
|
|
8
|
+
from pinecall.domain.names import Env, parse_env
|
|
9
|
+
from pinecall.log.reduce import Metered, Usage, usage_row
|
|
10
|
+
from pinecall.log.store import entry_of
|
|
11
|
+
from pinecall.postgres.pool import Pool, unbounded
|
|
12
|
+
|
|
13
|
+
# A quota is the month's: what the org used in the world in one calendar month, one row.
|
|
14
|
+
USED = """
|
|
15
|
+
SELECT calls, minutes, messages, input_tokens, output_tokens, characters, cost_usd
|
|
16
|
+
FROM usage_totals WHERE org = %(org)s AND env = %(env)s AND period = %(month)s
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# A budget is the org's in both worlds: a month's spend is every world's row of that month.
|
|
20
|
+
SPENT_IN = """
|
|
21
|
+
SELECT coalesce(sum(cost_usd), 0) AS spent FROM usage_totals
|
|
22
|
+
WHERE org = %(org)s AND period = %(month)s
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# The refold holds the table while it reads the log, so a summary written meanwhile waits and is
|
|
26
|
+
# counted after the table is rewritten, never twice and never lost.
|
|
27
|
+
HELD = "LOCK TABLE usage_totals IN EXCLUSIVE MODE"
|
|
28
|
+
|
|
29
|
+
SUMMARIES = """
|
|
30
|
+
SELECT head.org, head.env, entry.call, entry.seq, entry.ts, entry.agent, entry.type,
|
|
31
|
+
entry.ephemeral, entry.data
|
|
32
|
+
FROM call_log entry JOIN call_log_head head ON head.log = entry.log
|
|
33
|
+
WHERE entry.type = 'call.summary' AND head.org IS NOT NULL AND head.env IS NOT NULL
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
FORGOTTEN = "DELETE FROM usage_totals"
|
|
37
|
+
|
|
38
|
+
WRITTEN = """
|
|
39
|
+
INSERT INTO usage_totals
|
|
40
|
+
(org, env, period, calls, minutes, messages, input_tokens, output_tokens, characters, cost_usd)
|
|
41
|
+
VALUES (%(org)s, %(env)s, %(period)s, %(calls)s, %(minutes)s, %(messages)s, %(input_tokens)s,
|
|
42
|
+
%(output_tokens)s, %(characters)s, %(cost_usd)s)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
ROWS = """
|
|
46
|
+
SELECT org, env, period, calls, minutes, messages, input_tokens, output_tokens, characters,
|
|
47
|
+
cost_usd
|
|
48
|
+
FROM usage_totals
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, order=True)
|
|
53
|
+
class Month:
|
|
54
|
+
"""Whose a row of the totals is: the org, the world, the calendar month (UTC)."""
|
|
55
|
+
|
|
56
|
+
org: str
|
|
57
|
+
env: Env
|
|
58
|
+
period: date
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def used(pool: Pool, org: str, env: Env, month: date) -> Usage:
|
|
62
|
+
"""What the org's calls in the world consumed in the month: one row read."""
|
|
63
|
+
params = {"org": org, "env": env, "month": month.replace(day=1)}
|
|
64
|
+
async with pool.connection() as connection:
|
|
65
|
+
row = await (await connection.execute(USED, params)).fetchone()
|
|
66
|
+
return Usage() if row is None else _usage(row)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def spent_in(pool: Pool, org: str, month: date) -> float:
|
|
70
|
+
"""What the org's calls in both worlds cost in the month their summaries were written."""
|
|
71
|
+
params = {"org": org, "month": month.replace(day=1)}
|
|
72
|
+
async with pool.connection() as connection:
|
|
73
|
+
row = await (await connection.execute(SPENT_IN, params)).fetchone()
|
|
74
|
+
return 0.0 if row is None else float(row["spent"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def rebuild(pool: Pool) -> dict[Month, Usage]:
|
|
78
|
+
"""Refold the totals from every summary in the log, as the usage feed folds each one."""
|
|
79
|
+
async with unbounded(pool) as connection:
|
|
80
|
+
await connection.execute(HELD)
|
|
81
|
+
rows = await (await connection.execute(SUMMARIES)).fetchall()
|
|
82
|
+
refolded: dict[Month, Usage] = {}
|
|
83
|
+
for row in rows:
|
|
84
|
+
month = Month(str(row["org"]), parse_env(str(row["env"])), month_of(row["ts"]))
|
|
85
|
+
consumed = usage_row(Metered(position=0, org=month.org, entry=entry_of(row))).used
|
|
86
|
+
refolded[month] = refolded.get(month, Usage()) + consumed
|
|
87
|
+
await connection.execute(FORGOTTEN)
|
|
88
|
+
async with connection.cursor() as cursor:
|
|
89
|
+
await cursor.executemany(
|
|
90
|
+
WRITTEN,
|
|
91
|
+
[
|
|
92
|
+
{**vars(month), **_columns(consumed)}
|
|
93
|
+
for month, consumed in sorted(refolded.items())
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
return refolded
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def totals(pool: Pool) -> dict[Month, Usage]:
|
|
100
|
+
"""Every row of the totals, as the database keeps them."""
|
|
101
|
+
async with pool.connection() as connection:
|
|
102
|
+
rows = await (await connection.execute(ROWS)).fetchall()
|
|
103
|
+
return {
|
|
104
|
+
Month(str(row["org"]), parse_env(str(row["env"])), row["period"]): _usage(row)
|
|
105
|
+
for row in rows
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def month_of(ts: float) -> date:
|
|
110
|
+
"""The calendar month, in UTC, a summary written at that time is counted in."""
|
|
111
|
+
return datetime.fromtimestamp(ts, UTC).date().replace(day=1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _usage(row: DictRow) -> Usage:
|
|
115
|
+
return Usage(
|
|
116
|
+
calls=int(row["calls"]),
|
|
117
|
+
minutes=float(row["minutes"]),
|
|
118
|
+
messages=int(row["messages"]),
|
|
119
|
+
input_tokens=int(row["input_tokens"]),
|
|
120
|
+
output_tokens=int(row["output_tokens"]),
|
|
121
|
+
characters=int(row["characters"]),
|
|
122
|
+
cost_usd=float(row["cost_usd"]),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# A summary's usage has no judge calls: they are its score's, which the totals do not count.
|
|
127
|
+
def _columns(used: Usage) -> dict[str, object]:
|
|
128
|
+
return {
|
|
129
|
+
"calls": used.calls,
|
|
130
|
+
"minutes": used.minutes,
|
|
131
|
+
"messages": used.messages,
|
|
132
|
+
"input_tokens": used.input_tokens,
|
|
133
|
+
"output_tokens": used.output_tokens,
|
|
134
|
+
"characters": used.characters,
|
|
135
|
+
"cost_usd": used.cost_usd,
|
|
136
|
+
}
|