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
pinecall/cli/_load.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""`load`: synthetic calls held against a gateway exactly as workers hold them, and measured."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from functools import partial
|
|
14
|
+
from itertools import pairwise
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from pydantic import TypeAdapter
|
|
19
|
+
|
|
20
|
+
from pinecall.domain.call import CallContext, Route, new_call_id
|
|
21
|
+
from pinecall.domain.errors import DeclarationRefused, GatewayRefused
|
|
22
|
+
from pinecall.domain.names import SANDBOX
|
|
23
|
+
from pinecall.fleet.client import SEALED_WITHIN_S, TIMEOUT_S, GatewayClient
|
|
24
|
+
from pinecall.process.settings import Settings
|
|
25
|
+
from pinecall.session.call import Writing
|
|
26
|
+
from pinecall.wire.events import GATEWAY_WRITES, CallEnded, event_of
|
|
27
|
+
from pinecall.wire.frames import Entry, WireModel
|
|
28
|
+
from pinecall.wire.rest.calls import BatchedEntry, OpenCallRequest, SealCallRequest
|
|
29
|
+
|
|
30
|
+
# What the replayer never sends: the gateway's own kinds, and a tool's round trip, which the
|
|
31
|
+
# gateway writes too (the one the worker writes itself, end_call's, is skipped with them).
|
|
32
|
+
GATEWAY_KINDS = GATEWAY_WRITES | {"tool.call", "tool.result"}
|
|
33
|
+
|
|
34
|
+
ENDED = "call.ended"
|
|
35
|
+
|
|
36
|
+
# The channel needs no number: the load names no carrier.
|
|
37
|
+
CHANNEL = "web"
|
|
38
|
+
|
|
39
|
+
CALLER = "load"
|
|
40
|
+
|
|
41
|
+
OUTCOME = "load"
|
|
42
|
+
|
|
43
|
+
# Between a refusal and the next call, so a gateway that refuses every open is not hammered.
|
|
44
|
+
REFUSED_PAUSE_S = 1.0
|
|
45
|
+
|
|
46
|
+
LAG_EVERY_S = 0.1
|
|
47
|
+
|
|
48
|
+
# Over this, the generator measures its own loop and not the gateway.
|
|
49
|
+
LAG_WARNING_MS = 50.0
|
|
50
|
+
|
|
51
|
+
# Past the run's end, what a call still writing and sealing is given before it is cut.
|
|
52
|
+
GRACE_S = 2 * SEALED_WITHIN_S
|
|
53
|
+
|
|
54
|
+
# A call's writer, once everything it queued was answered or the grace cut the call, stops this
|
|
55
|
+
# soon; nothing is left to send but what a cut call never will.
|
|
56
|
+
LET_GO_S = 0.1
|
|
57
|
+
|
|
58
|
+
UNREACHABLE = "unreachable"
|
|
59
|
+
|
|
60
|
+
A_CALL_IN_A_PATH = re.compile(r"call_[0-9a-f]+")
|
|
61
|
+
|
|
62
|
+
NO_KEY = "PINECALL_WORKER_KEY is unset: the load knocks with the sandbox fleet's key"
|
|
63
|
+
|
|
64
|
+
NOT_A_SCRIPT = "--script {path}: {why}"
|
|
65
|
+
|
|
66
|
+
NOT_ENOUGH = "--calls {calls}: at least one"
|
|
67
|
+
|
|
68
|
+
_ENTRIES: TypeAdapter[list[Entry]] = TypeAdapter(list[Entry])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Step:
|
|
73
|
+
"""One entry of the script: how long after the call opened it is written, and what it is."""
|
|
74
|
+
|
|
75
|
+
at_s: float
|
|
76
|
+
type: str
|
|
77
|
+
event: WireModel
|
|
78
|
+
ephemeral: bool
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Script:
|
|
83
|
+
"""What one synthetic call writes: the worker's entries in order, then its call.ended."""
|
|
84
|
+
|
|
85
|
+
steps: tuple[Step, ...]
|
|
86
|
+
ending: Step
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Plan:
|
|
91
|
+
"""A run: the org and agent the calls are opened for, the script, and the curve."""
|
|
92
|
+
|
|
93
|
+
org: str
|
|
94
|
+
agent: str
|
|
95
|
+
script: Script
|
|
96
|
+
calls: int
|
|
97
|
+
ramp_s: float
|
|
98
|
+
hold_s: float
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Window:
|
|
103
|
+
"""When a run reaches its top and when it ends, on the monotonic clock."""
|
|
104
|
+
|
|
105
|
+
top_at: float
|
|
106
|
+
ends_at: float
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Tally:
|
|
111
|
+
"""What a run counted while it ran: calls, entries, latencies, refusals, logs, the loop."""
|
|
112
|
+
|
|
113
|
+
opened: int = 0
|
|
114
|
+
sealed: int = 0
|
|
115
|
+
held: int = 0
|
|
116
|
+
most_held: int = 0
|
|
117
|
+
sent: int = 0
|
|
118
|
+
durable: int = 0
|
|
119
|
+
at_top: int = 0
|
|
120
|
+
# From an entry queued to the log holding it: what the call waits, as a worker's does.
|
|
121
|
+
append_ms: list[float] = field(default_factory=list[float])
|
|
122
|
+
batches: list[int] = field(default_factory=list[int])
|
|
123
|
+
seal_ms: list[float] = field(default_factory=list[float])
|
|
124
|
+
refusals: Counter[str] = field(default_factory=Counter[str])
|
|
125
|
+
verified: int = 0
|
|
126
|
+
wrong: int = 0
|
|
127
|
+
unread: Counter[str] = field(default_factory=Counter[str])
|
|
128
|
+
lag_ms: list[float] = field(default_factory=list[float])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class Run:
|
|
133
|
+
"""What every call of a run shares: how a slot knocks, the plan, the tally and the window."""
|
|
134
|
+
|
|
135
|
+
knock: Callable[[], GatewayClient]
|
|
136
|
+
plan: Plan
|
|
137
|
+
tally: Tally
|
|
138
|
+
window: Window
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_verb(verb: argparse.ArgumentParser) -> None:
|
|
142
|
+
"""`load --org --agent --script --calls --ramp --minutes`."""
|
|
143
|
+
verb.add_argument("--org", required=True, help="the org the calls are opened for")
|
|
144
|
+
verb.add_argument("--agent", required=True, help="the agent the calls are opened for")
|
|
145
|
+
verb.add_argument("--script", required=True, help="a call's log as JSON, replayed per call")
|
|
146
|
+
verb.add_argument("--calls", required=True, type=int, help="how many at once at the top")
|
|
147
|
+
verb.add_argument("--ramp", required=True, type=float, help="seconds to reach them, linearly")
|
|
148
|
+
verb.add_argument("--minutes", required=True, type=float, help="how long the top is held")
|
|
149
|
+
verb.set_defaults(run=loaded)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def loaded(settings: Settings, args: argparse.Namespace) -> int:
|
|
153
|
+
"""Hold the calls against the gateway, then print what was measured."""
|
|
154
|
+
if settings.worker_key is None:
|
|
155
|
+
raise DeclarationRefused(NO_KEY)
|
|
156
|
+
if args.calls < 1:
|
|
157
|
+
raise DeclarationRefused(NOT_ENOUGH.format(calls=args.calls))
|
|
158
|
+
plan = Plan(
|
|
159
|
+
org=args.org,
|
|
160
|
+
agent=args.agent,
|
|
161
|
+
script=script_of(_entries_in(Path(args.script))),
|
|
162
|
+
calls=args.calls,
|
|
163
|
+
ramp_s=args.ramp,
|
|
164
|
+
hold_s=args.minutes * 60,
|
|
165
|
+
)
|
|
166
|
+
tally = asyncio.run(_measured(settings.gateway_url, settings.worker_key, plan))
|
|
167
|
+
sys.stdout.writelines(f"{line}\n" for line in report_of(tally, plan.hold_s))
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def written_by_worker(kind: str) -> bool:
|
|
172
|
+
"""Whether a worker writes this kind of entry, rather than the gateway itself."""
|
|
173
|
+
return kind not in GATEWAY_KINDS
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ts may step back between two entries of a real log: a step is never earlier than the last.
|
|
177
|
+
def script_of(entries: Sequence[Entry]) -> Script:
|
|
178
|
+
"""The worker's entries of a call's log, timed and read as the wire's; call.ended if absent."""
|
|
179
|
+
first = entries[0].ts
|
|
180
|
+
steps: list[Step] = []
|
|
181
|
+
ending: Step | None = None
|
|
182
|
+
at_s = 0.0
|
|
183
|
+
for entry in entries:
|
|
184
|
+
at_s = max(at_s, entry.ts - first)
|
|
185
|
+
if not written_by_worker(entry.type):
|
|
186
|
+
continue
|
|
187
|
+
step = Step(at_s, entry.type, event_of(entry), entry.ephemeral)
|
|
188
|
+
if entry.type == ENDED:
|
|
189
|
+
ending = step
|
|
190
|
+
else:
|
|
191
|
+
steps.append(step)
|
|
192
|
+
if ending is None:
|
|
193
|
+
over = CallEnded(
|
|
194
|
+
reason="caller_hung_up", ended_by="caller", ended_at=first + at_s, duration_s=at_s
|
|
195
|
+
)
|
|
196
|
+
ending = Step(at_s, ENDED, over, ephemeral=False)
|
|
197
|
+
return Script(tuple(steps), ending)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def starts_of(calls: int, ramp_s: float) -> list[float]:
|
|
201
|
+
"""When each slot starts, in seconds from the run's start: evenly along the ramp."""
|
|
202
|
+
return [ramp_s * slot / calls for slot in range(calls)]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def percentile(samples: Sequence[float], share: float) -> float | None:
|
|
206
|
+
"""The nearest-rank percentile of the samples (share 0.99 for p99); None for no sample."""
|
|
207
|
+
if not samples:
|
|
208
|
+
return None
|
|
209
|
+
ordered = sorted(samples)
|
|
210
|
+
return ordered[max(math.ceil(share * len(ordered)) - 1, 0)]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def rising(sent: Sequence[Entry]) -> bool:
|
|
214
|
+
"""Whether the seqs the gateway gave a call's entries rise strictly, in the order sent."""
|
|
215
|
+
return all(before.seq < after.seq for before, after in pairwise(sent))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# The kinds sent are compared: a duplicate from a retry is an entry of a sent kind at a new seq.
|
|
219
|
+
def log_holds(sent: Sequence[Entry], read: Sequence[Entry]) -> bool:
|
|
220
|
+
"""Whether a log read back holds every durable entry sent, once each, in order, and no other."""
|
|
221
|
+
kinds = {entry.type for entry in sent}
|
|
222
|
+
written = [(entry.seq, entry.type) for entry in sent if not entry.ephemeral]
|
|
223
|
+
return [(entry.seq, entry.type) for entry in read if entry.type in kinds] == written
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def run_load(knock: Callable[[], GatewayClient], plan: Plan) -> Tally:
|
|
227
|
+
"""Hold the plan's calls, each slot on a client of its own, and count what happened."""
|
|
228
|
+
tally = Tally()
|
|
229
|
+
began = time.monotonic()
|
|
230
|
+
window = Window(top_at=began + plan.ramp_s, ends_at=began + plan.ramp_s + plan.hold_s)
|
|
231
|
+
stopped = asyncio.Event()
|
|
232
|
+
try:
|
|
233
|
+
async with (
|
|
234
|
+
asyncio.timeout(plan.ramp_s + plan.hold_s + GRACE_S),
|
|
235
|
+
asyncio.TaskGroup() as group,
|
|
236
|
+
):
|
|
237
|
+
group.create_task(_lag(tally, stopped))
|
|
238
|
+
run = Run(knock, plan, tally, window)
|
|
239
|
+
slots = [
|
|
240
|
+
group.create_task(_slot(run, start)) for start in starts_of(plan.calls, plan.ramp_s)
|
|
241
|
+
]
|
|
242
|
+
await asyncio.wait(slots)
|
|
243
|
+
stopped.set()
|
|
244
|
+
except TimeoutError:
|
|
245
|
+
# Past the grace, a call still writing is cut; the report says how many were open.
|
|
246
|
+
pass
|
|
247
|
+
return tally
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def report_of(tally: Tally, hold_s: float) -> list[str]:
|
|
251
|
+
"""What a run measured, a line each, as a person and a script both read it."""
|
|
252
|
+
per_second = "-" if hold_s <= 0 else f"{tally.at_top / hold_s:.1f}"
|
|
253
|
+
lag = percentile(tally.lag_ms, 0.99)
|
|
254
|
+
lines = [
|
|
255
|
+
f"calls opened: {tally.opened}",
|
|
256
|
+
f"calls sealed: {tally.sealed}",
|
|
257
|
+
f"calls open at the end: {tally.held}",
|
|
258
|
+
f"most held at once: {tally.most_held}",
|
|
259
|
+
f"entries sent: {tally.sent} ({tally.durable} durable)",
|
|
260
|
+
f"entries a second at the top: {per_second}",
|
|
261
|
+
f"append ms: {_percentiles(tally.append_ms, (0.5, 0.95, 0.99))}",
|
|
262
|
+
f"entries per batch: {_percentiles([float(n) for n in tally.batches], (0.5, 0.99))}",
|
|
263
|
+
f"seal ms: {_percentiles(tally.seal_ms, (0.5, 0.99))}",
|
|
264
|
+
f"refusals: {_counted(tally.refusals)}",
|
|
265
|
+
f"logs verified: {tally.verified}",
|
|
266
|
+
f"logs found wrong: {tally.wrong}",
|
|
267
|
+
f"logs unread: {sum(tally.unread.values())} (refused: {_counted(tally.unread)})",
|
|
268
|
+
f"loop lag ms: p99 {_ms(lag)}",
|
|
269
|
+
]
|
|
270
|
+
if lag is not None and lag > LAG_WARNING_MS:
|
|
271
|
+
lines.append(
|
|
272
|
+
f"warning: this generator's own loop lagged {lag:.1f} ms at p99, over "
|
|
273
|
+
f"{LAG_WARNING_MS:.0f}: it measured itself, not the gateway; run fewer calls a process"
|
|
274
|
+
)
|
|
275
|
+
return lines
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# A worker's job is a process with a client of its own, so a slot has one too: a pool shared by
|
|
279
|
+
# every call is searched whole on each request, and the generator would measure that search.
|
|
280
|
+
async def _measured(url: str, key: str, plan: Plan) -> Tally:
|
|
281
|
+
headers = {"Authorization": f"Bearer {key}"}
|
|
282
|
+
# Built once: every client would otherwise load the machine's certificates for itself.
|
|
283
|
+
trusted = httpx.create_ssl_context()
|
|
284
|
+
|
|
285
|
+
def knock() -> GatewayClient:
|
|
286
|
+
http = httpx.AsyncClient(base_url=url, headers=headers, timeout=TIMEOUT_S, verify=trusted)
|
|
287
|
+
return GatewayClient(http)
|
|
288
|
+
|
|
289
|
+
return await run_load(knock, plan)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _entries_in(path: Path) -> list[Entry]:
|
|
293
|
+
try:
|
|
294
|
+
entries = _ENTRIES.validate_json(path.read_bytes())
|
|
295
|
+
except (OSError, ValueError) as unread:
|
|
296
|
+
raise DeclarationRefused(NOT_A_SCRIPT.format(path=path, why=unread)) from None
|
|
297
|
+
if not entries:
|
|
298
|
+
raise DeclarationRefused(NOT_A_SCRIPT.format(path=path, why="the log holds no entry"))
|
|
299
|
+
return entries
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def _lag(tally: Tally, stopped: asyncio.Event) -> None:
|
|
303
|
+
while not stopped.is_set():
|
|
304
|
+
before = time.monotonic()
|
|
305
|
+
await asyncio.sleep(LAG_EVERY_S)
|
|
306
|
+
tally.lag_ms.append((time.monotonic() - before - LAG_EVERY_S) * 1000)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# A refusal ends that call, never the slot: the slot opens a fresh call after a pause.
|
|
310
|
+
async def _slot(run: Run, start_s: float) -> None:
|
|
311
|
+
await asyncio.sleep(start_s)
|
|
312
|
+
client = run.knock()
|
|
313
|
+
try:
|
|
314
|
+
while time.monotonic() < run.window.ends_at:
|
|
315
|
+
try:
|
|
316
|
+
await _call(run, client)
|
|
317
|
+
except GatewayRefused as refused:
|
|
318
|
+
run.tally.refusals[_status(refused)] += 1
|
|
319
|
+
await asyncio.sleep(REFUSED_PAUSE_S)
|
|
320
|
+
finally:
|
|
321
|
+
await client.aclose()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def _call(run: Run, client: GatewayClient) -> None:
|
|
325
|
+
plan, tally = run.plan, run.tally
|
|
326
|
+
context = CallContext(
|
|
327
|
+
call=new_call_id(),
|
|
328
|
+
channel=CHANNEL,
|
|
329
|
+
direction="inbound",
|
|
330
|
+
caller=CALLER,
|
|
331
|
+
route=Route(org=plan.org, agent=plan.agent, channel=CHANNEL, env=SANDBOX),
|
|
332
|
+
today=datetime.now(UTC).date(),
|
|
333
|
+
)
|
|
334
|
+
await client.open(OpenCallRequest(agent=plan.agent, context=context))
|
|
335
|
+
tally.opened += 1
|
|
336
|
+
tally.held += 1
|
|
337
|
+
tally.most_held = max(tally.most_held, tally.held)
|
|
338
|
+
try:
|
|
339
|
+
sent = await _replayed(run, client, context.call)
|
|
340
|
+
started = time.monotonic()
|
|
341
|
+
await client.sealed(context.call, SealCallRequest(usage=[], outcome=OUTCOME))
|
|
342
|
+
tally.seal_ms.append((time.monotonic() - started) * 1000)
|
|
343
|
+
tally.sealed += 1
|
|
344
|
+
finally:
|
|
345
|
+
tally.held -= 1
|
|
346
|
+
await _verified(run, client, context.call, sent)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# At the script's own times from the open, queued on the worker's own writer, which sends what
|
|
350
|
+
# is queued as one batch while the last is out; a call the run's end overtakes skips to its
|
|
351
|
+
# call.ended. A refusal ends the call once what it queued has been answered.
|
|
352
|
+
async def _replayed(run: Run, client: GatewayClient, call: str) -> list[Entry]:
|
|
353
|
+
script, ends_at = run.plan.script, run.window.ends_at
|
|
354
|
+
|
|
355
|
+
async def batched(entries: list[BatchedEntry], *, after: int) -> list[Entry]:
|
|
356
|
+
run.tally.batches.append(len(entries))
|
|
357
|
+
return await client.append_many(call, entries, after=after)
|
|
358
|
+
|
|
359
|
+
writing = Writing(batched, call)
|
|
360
|
+
writing.open()
|
|
361
|
+
opened_at = time.monotonic()
|
|
362
|
+
written: list[asyncio.Future[Entry]] = []
|
|
363
|
+
try:
|
|
364
|
+
for step in (*script.steps, script.ending):
|
|
365
|
+
due = opened_at + step.at_s
|
|
366
|
+
if due >= ends_at and step is not script.ending:
|
|
367
|
+
continue
|
|
368
|
+
await asyncio.sleep(max(min(due, ends_at) - time.monotonic(), 0.0))
|
|
369
|
+
written.append(_queued(run, writing, step))
|
|
370
|
+
answered = await asyncio.gather(*written, return_exceptions=True)
|
|
371
|
+
finally:
|
|
372
|
+
await writing.close(LET_GO_S)
|
|
373
|
+
sent: list[Entry] = []
|
|
374
|
+
for result in answered:
|
|
375
|
+
if isinstance(result, BaseException):
|
|
376
|
+
raise result
|
|
377
|
+
sent.append(result)
|
|
378
|
+
return sent
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _queued(run: Run, writing: Writing, step: Step) -> asyncio.Future[Entry]:
|
|
382
|
+
queued_at = time.monotonic()
|
|
383
|
+
written = writing.write(step.type, step.event, ephemeral=step.ephemeral)
|
|
384
|
+
written.add_done_callback(partial(_landed, run, queued_at))
|
|
385
|
+
return written
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _landed(run: Run, queued_at: float, written: asyncio.Future[Entry]) -> None:
|
|
389
|
+
if written.cancelled() or written.exception() is not None:
|
|
390
|
+
return
|
|
391
|
+
tally, done = run.tally, time.monotonic()
|
|
392
|
+
tally.append_ms.append((done - queued_at) * 1000)
|
|
393
|
+
tally.sent += 1
|
|
394
|
+
tally.durable += 0 if written.result().ephemeral else 1
|
|
395
|
+
if run.window.top_at <= done <= run.window.ends_at:
|
|
396
|
+
tally.at_top += 1
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# Read through the log's own door as a reader pages it; a key that may not read is counted apart.
|
|
400
|
+
async def _verified(run: Run, client: GatewayClient, call: str, sent: list[Entry]) -> None:
|
|
401
|
+
tally = run.tally
|
|
402
|
+
if not rising(sent):
|
|
403
|
+
tally.wrong += 1
|
|
404
|
+
return
|
|
405
|
+
try:
|
|
406
|
+
read = [entry async for entry in client.since(call, 0)]
|
|
407
|
+
except GatewayRefused as refused:
|
|
408
|
+
tally.unread[_status(refused)] += 1
|
|
409
|
+
return
|
|
410
|
+
if log_holds(sent, read):
|
|
411
|
+
tally.verified += 1
|
|
412
|
+
else:
|
|
413
|
+
tally.wrong += 1
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# By status and door, the call's id taken out: which request was refused is what a run under
|
|
417
|
+
# gateways killed must say (`unreachable POST /v1/calls`), not a count of statuses alone.
|
|
418
|
+
def _status(refused: GatewayRefused) -> str:
|
|
419
|
+
status = UNREACHABLE if refused.answered is None else str(refused.answered)
|
|
420
|
+
door = A_CALL_IN_A_PATH.sub("{call}", str(refused).split(": ", 1)[0])
|
|
421
|
+
return f"{status} {door}"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _percentiles(samples: Sequence[float], shares: Sequence[float]) -> str:
|
|
425
|
+
return " ".join(f"p{round(share * 100)} {_ms(percentile(samples, share))}" for share in shares)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _ms(value: float | None) -> str:
|
|
429
|
+
return "-" if value is None else f"{value:.1f}"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _counted(counts: Counter[str]) -> str:
|
|
433
|
+
return " ".join(f"{name}={count}" for name, count in sorted(counts.items())) or "none"
|