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/fleet/client.py
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""The worker's one door to the platform: the gateway over HTTP, on its fleet's key."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import (
|
|
9
|
+
AsyncGenerator,
|
|
10
|
+
AsyncIterator,
|
|
11
|
+
Awaitable,
|
|
12
|
+
Callable,
|
|
13
|
+
Iterator,
|
|
14
|
+
Mapping,
|
|
15
|
+
Sequence,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from opentelemetry import propagate
|
|
20
|
+
from pydantic import TypeAdapter
|
|
21
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
22
|
+
from websockets.exceptions import ConnectionClosed, InvalidHandshake
|
|
23
|
+
|
|
24
|
+
from pinecall.domain.agent import AgentConfig
|
|
25
|
+
from pinecall.domain.call import Route
|
|
26
|
+
from pinecall.domain.errors import GatewayRefused
|
|
27
|
+
from pinecall.domain.names import Channel, Json, JsonObject
|
|
28
|
+
from pinecall.domain.scope import Scope
|
|
29
|
+
from pinecall.wire.frames import Command, Entry
|
|
30
|
+
from pinecall.wire.parts import PlatformTool, ToolResult
|
|
31
|
+
from pinecall.wire.rest.agents import HoldAudio, RingHandoff
|
|
32
|
+
from pinecall.wire.rest.calls import (
|
|
33
|
+
AppendEntriesRefused,
|
|
34
|
+
AppendEntriesRequest,
|
|
35
|
+
AppendEntriesResponse,
|
|
36
|
+
BatchedEntry,
|
|
37
|
+
CallbackRequest,
|
|
38
|
+
LogPage,
|
|
39
|
+
OpenCallRequest,
|
|
40
|
+
OpenCallResponse,
|
|
41
|
+
RecordingKeyResponse,
|
|
42
|
+
SealCallRequest,
|
|
43
|
+
)
|
|
44
|
+
from pinecall.wire.rest.fleet import HeartbeatRequest, HeartbeatResponse
|
|
45
|
+
from pinecall.wire.rest.numbers import LegTrunk, LegTrunkResponse
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# A caller is waiting on the line.
|
|
51
|
+
TIMEOUT_S = 5.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# A clip is a few hundred KB, fetched once per box.
|
|
55
|
+
A_FILE_S = 15.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Under the job's sealing budget (worker/main.py); what does not seal here the reaper seals. One
|
|
59
|
+
# knock waits all of it: a seal writes memory and asks the judges, which outlast TIMEOUT_S.
|
|
60
|
+
SEALED_WITHIN_S = 30.0
|
|
61
|
+
|
|
62
|
+
# The header a balancer keeps a call's requests on one gateway by: `/v1/calls/{call}/...`.
|
|
63
|
+
CALL_HEADER = "Pinecall-Call"
|
|
64
|
+
CALL_AT = 3
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# A restarting gateway is back in seconds; the cap keeps the log catching up after it.
|
|
68
|
+
FIRST_WAIT_S = 0.5
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
LONGEST_WAIT_S = 5.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
SAID_EVERY = 10
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
NOT_SERVED = 404
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
ALREADY_OVER = 409
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# A caller is waiting on the line while the open is asked again.
|
|
84
|
+
OPENED_WITHIN_S = 15.0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# The socket a call's batches go down: `WS /v1/calls/{call}/entries`.
|
|
88
|
+
STREAM_DOOR = "/v1/calls/{call}/entries"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
EVENT_STREAM = "text/event-stream"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_ROUTES: TypeAdapter[list[Route]] = TypeAdapter(list[Route])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_CONFIG: TypeAdapter[AgentConfig] = TypeAdapter(AgentConfig)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_OBJECT: TypeAdapter[JsonObject] = TypeAdapter(JsonObject)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class GatewayClient:
|
|
104
|
+
"""The gateway as a worker asks it: every door it knocks, on one connection pool."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, http: httpx.AsyncClient) -> None:
|
|
107
|
+
"""No call opened yet."""
|
|
108
|
+
self.http = http
|
|
109
|
+
# What each unsealed call was opened with, said again to a gateway that restarted.
|
|
110
|
+
self.opened: dict[str, OpenCallRequest] = {}
|
|
111
|
+
# Each unsealed call's socket for its batches; none once a gateway had no such door.
|
|
112
|
+
self.streams: dict[str, EntriesStream] = {}
|
|
113
|
+
self.doorless = False
|
|
114
|
+
|
|
115
|
+
# ── an agent, before its call ──
|
|
116
|
+
|
|
117
|
+
async def routes(
|
|
118
|
+
self, scope: Scope | None, *, number: str | None, channel: Channel
|
|
119
|
+
) -> list[Route]:
|
|
120
|
+
"""The routes of the dispatch's scope; with no scope, the one a number rings, any org."""
|
|
121
|
+
params = _scope_headers(scope)
|
|
122
|
+
if number is not None:
|
|
123
|
+
params |= {"number": number, "channel": channel}
|
|
124
|
+
return _ROUTES.validate_python(await self._read("GET", "/v1/routes", params=params))
|
|
125
|
+
|
|
126
|
+
# The call, not opened yet, is named so a canary's share is picked by its id.
|
|
127
|
+
async def agent(self, slug: str, scope: Scope, *, call: str | None = None) -> AgentConfig:
|
|
128
|
+
"""The agent as the scope runs it for the call: its declaration under the settings."""
|
|
129
|
+
params = _scope_headers(scope) | ({} if call is None else {"for_call": call})
|
|
130
|
+
text = await self._read("GET", f"/v1/agents/{slug}/config", params=params)
|
|
131
|
+
return _CONFIG.validate_python(text)
|
|
132
|
+
|
|
133
|
+
# The one answer that carries keys, and it carries them to the fleet's key alone.
|
|
134
|
+
async def stages(self, slug: str, scope: Scope, *, call: str | None = None) -> Json:
|
|
135
|
+
"""The three stages the call runs, each with the key it runs on."""
|
|
136
|
+
params = _scope_headers(scope) | ({} if call is None else {"for_call": call})
|
|
137
|
+
return await self._read("GET", f"/v1/agents/{slug}/provider-keys", params=params)
|
|
138
|
+
|
|
139
|
+
async def hold_audio(self, slug: str, scope: Scope) -> HoldAudio:
|
|
140
|
+
"""What the agent plays while a tool runs."""
|
|
141
|
+
text = await self._read(
|
|
142
|
+
"GET", f"/v1/agents/{slug}/hold-audio", params=_scope_headers(scope)
|
|
143
|
+
)
|
|
144
|
+
return HoldAudio.model_validate(text)
|
|
145
|
+
|
|
146
|
+
async def hold_clip(self, slug: str, scope: Scope) -> bytes:
|
|
147
|
+
"""The org's own clip, Ogg Opus."""
|
|
148
|
+
path = f"/v1/agents/{slug}/hold-audio/audio"
|
|
149
|
+
answer = await self._answered("GET", path, None, A_FILE_S, _scope_headers(scope))
|
|
150
|
+
return answer.content
|
|
151
|
+
|
|
152
|
+
async def rings_for(self, slug: str, org: str, caller: str) -> RingHandoff:
|
|
153
|
+
"""Whether a production ring from this phone belongs in a developer's sandbox."""
|
|
154
|
+
params = {"org": org, "caller": caller}
|
|
155
|
+
text = await self._read("GET", f"/v1/agents/{slug}/rings-for", params=params)
|
|
156
|
+
return RingHandoff.model_validate(text)
|
|
157
|
+
|
|
158
|
+
# The gateway judges the leg (shape and pace) and names the trunk; a refusal is its sentence.
|
|
159
|
+
async def leg(
|
|
160
|
+
self, slug: str, scope: Scope, *, to: str, call: str, shown: str | None
|
|
161
|
+
) -> LegTrunk:
|
|
162
|
+
"""How to dial a leg of the call: the trunk inline and the number shown."""
|
|
163
|
+
params = (
|
|
164
|
+
_scope_headers(scope) | {"to": to, "call": call} | ({"from": shown} if shown else {})
|
|
165
|
+
)
|
|
166
|
+
text = await self._read("GET", f"/v1/agents/{slug}/outbound-trunk", params=params)
|
|
167
|
+
return LegTrunkResponse.model_validate(text).trunk
|
|
168
|
+
|
|
169
|
+
# A cold transfer dials nothing of ours, so no trunk: the gateway judges the leg alone.
|
|
170
|
+
async def sent_on(self, slug: str, scope: Scope, *, to: str, call: str) -> None:
|
|
171
|
+
"""Have the gateway judge a cold transfer's leg; its refusal is its sentence."""
|
|
172
|
+
params = _scope_headers(scope) | {"to": to, "call": call}
|
|
173
|
+
await self._read("POST", f"/v1/agents/{slug}/cold-transfer", params=params)
|
|
174
|
+
|
|
175
|
+
# ── a call ──
|
|
176
|
+
|
|
177
|
+
# Asked again while the gateway is away: an open is the same call by its id (one claim, one
|
|
178
|
+
# opening, one ringing), so an answer lost with a gateway that died is not a call lost.
|
|
179
|
+
async def open(self, opening: OpenCallRequest) -> OpenCallResponse:
|
|
180
|
+
"""Open the call's log; the answer is what the org's minutes leave it."""
|
|
181
|
+
body = opening.written()
|
|
182
|
+
data = await again(lambda: self._read("POST", "/v1/calls", body), OPENED_WITHIN_S, "open")
|
|
183
|
+
self.opened[opening.context.call] = opening
|
|
184
|
+
return OpenCallResponse.model_validate(data)
|
|
185
|
+
|
|
186
|
+
# Retried without a limit, the same batch after the same count: the log answers a batch it
|
|
187
|
+
# already took with the seqs it gave it, so an answer lost never writes an entry twice. The
|
|
188
|
+
# batches go down one socket per call; a gateway of before has no such door, and from the
|
|
189
|
+
# first that says so every call's batches go by request.
|
|
190
|
+
async def append_many(
|
|
191
|
+
self, call: str, entries: Sequence[BatchedEntry], *, after: int
|
|
192
|
+
) -> list[Entry]:
|
|
193
|
+
"""Write a batch of the call's entries once, in order; the gateway numbers them."""
|
|
194
|
+
path = STREAM_DOOR.format(call=call)
|
|
195
|
+
if not self.doorless:
|
|
196
|
+
stream = self.streams.get(call) or self._stream_of(call)
|
|
197
|
+
numbered = await again(
|
|
198
|
+
lambda: self._streamed_batch(call, stream, entries, after), None, path
|
|
199
|
+
)
|
|
200
|
+
if numbered is not None:
|
|
201
|
+
return numbered
|
|
202
|
+
self.doorless = True
|
|
203
|
+
self.streams.pop(call, None)
|
|
204
|
+
body = AppendEntriesRequest(after=after, entries=list(entries)).written()
|
|
205
|
+
door = f"/v1/calls/{call}/entries"
|
|
206
|
+
answer = await again(lambda: self._on_the_call(call, "POST", door, body), None, door)
|
|
207
|
+
return AppendEntriesResponse.model_validate(answer).entries
|
|
208
|
+
|
|
209
|
+
# The gateway answers a lapsed tool at its own deadline: wait past it, so the model reads
|
|
210
|
+
# that answer and not a timeout of ours. Idempotent per call id, so a retry joins.
|
|
211
|
+
async def tool(self, call: str, agent: str, called: JsonObject, timeout_s: float) -> ToolResult:
|
|
212
|
+
"""Run a tool in the app that declared it and return what it answered."""
|
|
213
|
+
waiting = timeout_s + TIMEOUT_S
|
|
214
|
+
path = f"/v1/calls/{call}/tools?agent={agent}"
|
|
215
|
+
answer = await again(
|
|
216
|
+
lambda: self._on_the_call(call, "POST", path, called, waiting), waiting, path
|
|
217
|
+
)
|
|
218
|
+
return ToolResult.model_validate(answer)
|
|
219
|
+
|
|
220
|
+
async def sealed(self, call: str, sealing: SealCallRequest) -> None:
|
|
221
|
+
"""Hand the end of the call to the gateway, which prices, judges and seals it."""
|
|
222
|
+
path = f"/v1/calls/{call}/sealed"
|
|
223
|
+
body = sealing.written()
|
|
224
|
+
try:
|
|
225
|
+
await again(
|
|
226
|
+
lambda: self._on_the_call(call, "POST", path, body, SEALED_WITHIN_S),
|
|
227
|
+
SEALED_WITHIN_S,
|
|
228
|
+
path,
|
|
229
|
+
)
|
|
230
|
+
except GatewayRefused as refused:
|
|
231
|
+
# Sealed already: by this seal, whose answer a gateway that died took with it.
|
|
232
|
+
if refused.answered != ALREADY_OVER:
|
|
233
|
+
raise
|
|
234
|
+
self.opened.pop(call, None)
|
|
235
|
+
await self._stream_closed(call)
|
|
236
|
+
|
|
237
|
+
async def lookup(
|
|
238
|
+
self, call: str, tool: PlatformTool, arguments: Mapping[str, Json], speech: str | None
|
|
239
|
+
) -> JsonObject:
|
|
240
|
+
"""Recall or search, run by the gateway, which writes what it found on the log."""
|
|
241
|
+
data: JsonObject = {"tool": tool, "input": dict(arguments), "speech_id": speech}
|
|
242
|
+
answer = _OBJECT.validate_python(
|
|
243
|
+
await self._on_the_call(call, "POST", f"/v1/calls/{call}/lookup", data)
|
|
244
|
+
)
|
|
245
|
+
return _OBJECT.validate_python(answer.get("output") or {})
|
|
246
|
+
|
|
247
|
+
# Asked once, at the call's end: a gateway away leaves the recording plain, never unsealed
|
|
248
|
+
# waiting. A gateway of before sealed recordings has no such door and answers 404.
|
|
249
|
+
async def recording_key(self, call: str) -> bytes | None:
|
|
250
|
+
"""The key the call's recording is sealed under; None from a gateway that seals none."""
|
|
251
|
+
try:
|
|
252
|
+
answer = await self._on_the_call(call, "POST", f"/v1/calls/{call}/recording/key")
|
|
253
|
+
except GatewayRefused as refused:
|
|
254
|
+
if refused.answered != NOT_SERVED:
|
|
255
|
+
raise
|
|
256
|
+
return None
|
|
257
|
+
return base64.urlsafe_b64decode(RecordingKeyResponse.model_validate(answer).key)
|
|
258
|
+
|
|
259
|
+
# A code nobody issued is an ordinary answer: the caller may be dialling an extension.
|
|
260
|
+
async def claim(self, call: str, code: str) -> bool:
|
|
261
|
+
"""Tie the call to the page showing the code; False when no page waits on it."""
|
|
262
|
+
try:
|
|
263
|
+
await self._on_the_call(call, "POST", f"/v1/calls/{call}/claim", {"code": code})
|
|
264
|
+
except GatewayRefused as refused:
|
|
265
|
+
if refused.answered != NOT_SERVED:
|
|
266
|
+
raise
|
|
267
|
+
return False
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
async def state(self, call: str) -> JsonObject:
|
|
271
|
+
"""The call's state as the gateway folds it, with the seq it was folded to."""
|
|
272
|
+
return _OBJECT.validate_python(
|
|
273
|
+
await self._on_the_call(call, "GET", f"/v1/calls/{call}/state")
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
async def since(self, call: str, after: int) -> AsyncIterator[Entry]:
|
|
277
|
+
"""The stored entries above the cursor, page by page, until the log's end."""
|
|
278
|
+
cursor = after
|
|
279
|
+
while True:
|
|
280
|
+
path = f"/v1/calls/{call}/events?after={cursor}"
|
|
281
|
+
page = await again(lambda p=path: self._read("GET", p), SEALED_WITHIN_S, path)
|
|
282
|
+
if page is None:
|
|
283
|
+
return
|
|
284
|
+
read = LogPage.model_validate(page)
|
|
285
|
+
for entry in read.entries:
|
|
286
|
+
yield Entry.model_validate(entry)
|
|
287
|
+
if read.next is None:
|
|
288
|
+
return
|
|
289
|
+
cursor = read.next
|
|
290
|
+
|
|
291
|
+
async def tail(self, call: str, after: int) -> AsyncIterator[Entry]:
|
|
292
|
+
"""The entries above the cursor, then the live ones, until the call ends."""
|
|
293
|
+
async for frame in self._streamed(f"/v1/calls/{call}/events?after={after}"):
|
|
294
|
+
yield Entry.model_validate(frame)
|
|
295
|
+
|
|
296
|
+
# A stream that ends means the gateway went away; a 404 means it forgot the call.
|
|
297
|
+
async def commands(self, call: str) -> AsyncGenerator[Command]:
|
|
298
|
+
"""The app's commands for the call, in the order it sent them, until the call is sealed."""
|
|
299
|
+
path = f"/v1/calls/{call}/commands"
|
|
300
|
+
try:
|
|
301
|
+
async for frame in self._streamed(path):
|
|
302
|
+
yield Command.model_validate(frame)
|
|
303
|
+
except GatewayRefused as refused:
|
|
304
|
+
if not await self._reopened(call, refused):
|
|
305
|
+
raise
|
|
306
|
+
else:
|
|
307
|
+
return
|
|
308
|
+
async for frame in self._streamed(path):
|
|
309
|
+
yield Command.model_validate(frame)
|
|
310
|
+
|
|
311
|
+
# ── the fleet ──
|
|
312
|
+
|
|
313
|
+
async def heartbeat(self, beat: HeartbeatRequest) -> HeartbeatResponse:
|
|
314
|
+
"""Report this worker; the answer says whether it is cordoned."""
|
|
315
|
+
return HeartbeatResponse.model_validate(
|
|
316
|
+
await self._read("POST", "/v1/fleet/heartbeat", beat.written())
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
async def callback(self, wanted: CallbackRequest) -> None:
|
|
320
|
+
"""Somebody the overflow told to wait for a call back."""
|
|
321
|
+
await self._read("POST", "/v1/callbacks", wanted.written())
|
|
322
|
+
|
|
323
|
+
async def aclose(self) -> None:
|
|
324
|
+
"""Close every call's socket and the connection pool."""
|
|
325
|
+
for call in list(self.streams):
|
|
326
|
+
await self._stream_closed(call)
|
|
327
|
+
await self.http.aclose()
|
|
328
|
+
|
|
329
|
+
# ── the wire ──
|
|
330
|
+
|
|
331
|
+
async def _read(
|
|
332
|
+
self,
|
|
333
|
+
method: str,
|
|
334
|
+
path: str,
|
|
335
|
+
data: Json = None,
|
|
336
|
+
wait_s: float | None = None,
|
|
337
|
+
params: Mapping[str, str] | None = None,
|
|
338
|
+
) -> Json:
|
|
339
|
+
answer = await self._answered(method, path, data, wait_s, params)
|
|
340
|
+
return answer.json() if answer.content else None
|
|
341
|
+
|
|
342
|
+
async def _answered(
|
|
343
|
+
self,
|
|
344
|
+
method: str,
|
|
345
|
+
path: str,
|
|
346
|
+
data: Json,
|
|
347
|
+
wait_s: float | None,
|
|
348
|
+
params: Mapping[str, str] | None,
|
|
349
|
+
) -> httpx.Response:
|
|
350
|
+
try:
|
|
351
|
+
answer = await self.http.request(
|
|
352
|
+
method,
|
|
353
|
+
path,
|
|
354
|
+
json=data,
|
|
355
|
+
timeout=wait_s or TIMEOUT_S,
|
|
356
|
+
params=params or None,
|
|
357
|
+
headers={**traced(), **affine(path)},
|
|
358
|
+
)
|
|
359
|
+
except httpx.HTTPError as unreachable:
|
|
360
|
+
raise GatewayRefused(f"{method} {path}: {unreachable}") from unreachable
|
|
361
|
+
if answer.status_code >= httpx.codes.BAD_REQUEST:
|
|
362
|
+
raise GatewayRefused(
|
|
363
|
+
f"{method} {path}: {answer.status_code} {answer.text}", answered=answer.status_code
|
|
364
|
+
)
|
|
365
|
+
return answer
|
|
366
|
+
|
|
367
|
+
# A 404 for a call this worker opened is a gateway that restarted: say the call again, once.
|
|
368
|
+
async def _on_the_call(
|
|
369
|
+
self, call: str, method: str, path: str, data: Json = None, wait_s: float | None = None
|
|
370
|
+
) -> Json:
|
|
371
|
+
try:
|
|
372
|
+
return await self._read(method, path, data, wait_s)
|
|
373
|
+
except GatewayRefused as refused:
|
|
374
|
+
if not await self._reopened(call, refused):
|
|
375
|
+
raise
|
|
376
|
+
return await self._read(method, path, data, wait_s)
|
|
377
|
+
|
|
378
|
+
def _stream_of(self, call: str) -> "EntriesStream":
|
|
379
|
+
path = STREAM_DOOR.format(call=call)
|
|
380
|
+
base = str(self.http.base_url).rstrip("/")
|
|
381
|
+
url = f"ws{base.removeprefix('http')}{path}"
|
|
382
|
+
# The key alone of the pool's headers: its keep-alive and encodings are not a handshake's.
|
|
383
|
+
bearer = self.http.headers.get("Authorization")
|
|
384
|
+
headers = {**({"Authorization": bearer} if bearer else {}), **traced(), **affine(path)}
|
|
385
|
+
stream = EntriesStream(url, headers, call)
|
|
386
|
+
self.streams[call] = stream
|
|
387
|
+
return stream
|
|
388
|
+
|
|
389
|
+
# A 404 on the socket is the gateway that forgot the call, as on a request; None is a gateway
|
|
390
|
+
# with no such door at all, which the batch door serves.
|
|
391
|
+
async def _streamed_batch(
|
|
392
|
+
self, call: str, stream: "EntriesStream", entries: Sequence[BatchedEntry], after: int
|
|
393
|
+
) -> list[Entry] | None:
|
|
394
|
+
try:
|
|
395
|
+
return await stream.append_many(entries, after=after)
|
|
396
|
+
except GatewayRefused as refused:
|
|
397
|
+
if stream.doorless:
|
|
398
|
+
return None
|
|
399
|
+
if not await self._reopened(call, refused):
|
|
400
|
+
raise
|
|
401
|
+
return await stream.append_many(entries, after=after)
|
|
402
|
+
|
|
403
|
+
async def _stream_closed(self, call: str) -> None:
|
|
404
|
+
stream = self.streams.pop(call, None)
|
|
405
|
+
if stream is not None:
|
|
406
|
+
await stream.close()
|
|
407
|
+
|
|
408
|
+
async def _reopened(self, call: str, refused: GatewayRefused) -> bool:
|
|
409
|
+
opening = self.opened.get(call)
|
|
410
|
+
if refused.answered != NOT_SERVED or opening is None:
|
|
411
|
+
return False
|
|
412
|
+
await self._read("POST", f"/v1/calls/{call}/reopened", opening.written())
|
|
413
|
+
return True
|
|
414
|
+
|
|
415
|
+
async def _streamed(self, path: str) -> AsyncIterator[JsonObject]:
|
|
416
|
+
try:
|
|
417
|
+
async with self.http.stream(
|
|
418
|
+
"GET", path, headers={"Accept": EVENT_STREAM, **affine(path)}, timeout=STREAMED
|
|
419
|
+
) as stream:
|
|
420
|
+
if stream.status_code >= httpx.codes.BAD_REQUEST:
|
|
421
|
+
raise GatewayRefused(
|
|
422
|
+
f"GET {path}: {stream.status_code}", answered=stream.status_code
|
|
423
|
+
)
|
|
424
|
+
async for frame in server_sent(stream.aiter_lines()):
|
|
425
|
+
yield frame
|
|
426
|
+
except httpx.HTTPError as unreachable:
|
|
427
|
+
raise GatewayRefused(f"GET {path}: {unreachable}") from unreachable
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# One batch is out at a time (the call's writer sends the next when this one is answered), so an
|
|
431
|
+
# answer is the last frame sent. An answer that does not come within the request's timeout is
|
|
432
|
+
# not waited for past it: the socket is dropped, and the retry asks again on a new one.
|
|
433
|
+
class EntriesStream:
|
|
434
|
+
"""A call's batches down one WebSocket to the gateway, each answered with its seqs."""
|
|
435
|
+
|
|
436
|
+
def __init__(self, url: str, headers: Mapping[str, str], call: str) -> None:
|
|
437
|
+
"""Not connected yet: the first batch connects."""
|
|
438
|
+
self.url = url
|
|
439
|
+
self.headers = dict(headers)
|
|
440
|
+
self.call = call
|
|
441
|
+
self.socket: ClientConnection | None = None
|
|
442
|
+
# True once the gateway refused the upgrade: it has no such door.
|
|
443
|
+
self.doorless = False
|
|
444
|
+
self._sending = asyncio.Lock()
|
|
445
|
+
|
|
446
|
+
async def append_many(self, entries: Sequence[BatchedEntry], *, after: int) -> list[Entry]:
|
|
447
|
+
"""Send the batch and read its answer; GatewayRefused when the socket or the log did."""
|
|
448
|
+
frame = AppendEntriesRequest(after=after, entries=list(entries)).model_dump_json()
|
|
449
|
+
async with self._sending:
|
|
450
|
+
socket = await self._connected()
|
|
451
|
+
try:
|
|
452
|
+
await socket.send(frame)
|
|
453
|
+
raw = await asyncio.wait_for(socket.recv(), TIMEOUT_S)
|
|
454
|
+
except (ConnectionClosed, OSError, TimeoutError) as lost:
|
|
455
|
+
await self.close()
|
|
456
|
+
raise GatewayRefused(f"WS {self.url}: {lost}") from lost
|
|
457
|
+
answer = json.loads(raw)
|
|
458
|
+
if isinstance(answer, dict) and "refused" in answer:
|
|
459
|
+
refused = AppendEntriesRefused.model_validate(answer)
|
|
460
|
+
raise GatewayRefused(
|
|
461
|
+
f"WS {self.url}: {refused.status} {refused.refused}", answered=refused.status
|
|
462
|
+
)
|
|
463
|
+
return AppendEntriesResponse.model_validate(answer).entries
|
|
464
|
+
|
|
465
|
+
async def close(self) -> None:
|
|
466
|
+
"""Close the socket; the next batch opens another."""
|
|
467
|
+
socket, self.socket = self.socket, None
|
|
468
|
+
if socket is not None:
|
|
469
|
+
await socket.close()
|
|
470
|
+
|
|
471
|
+
async def _connected(self) -> ClientConnection:
|
|
472
|
+
if self.socket is not None:
|
|
473
|
+
return self.socket
|
|
474
|
+
try:
|
|
475
|
+
self.socket = await connect(
|
|
476
|
+
self.url, additional_headers=self.headers, open_timeout=TIMEOUT_S
|
|
477
|
+
)
|
|
478
|
+
except InvalidHandshake as refused:
|
|
479
|
+
self.doorless = True
|
|
480
|
+
raise GatewayRefused(f"WS {self.url}: {refused}", answered=NOT_SERVED) from refused
|
|
481
|
+
except (OSError, TimeoutError) as unreachable:
|
|
482
|
+
raise GatewayRefused(f"WS {self.url}: {unreachable}") from unreachable
|
|
483
|
+
return self.socket
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# A stream is timed only on connect: a quiet one is kept open by the gateway's ping every 25 s.
|
|
487
|
+
STREAMED = httpx.Timeout(TIMEOUT_S, read=None)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def gateway_at(url: str, key: str | None) -> GatewayClient:
|
|
491
|
+
"""The gateway at this URL, knocked on with the fleet's key in the header, never the URL."""
|
|
492
|
+
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
493
|
+
return GatewayClient(httpx.AsyncClient(base_url=url, headers=headers, timeout=TIMEOUT_S))
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# `data:` holds the whole entry, its seq and type included: `id:`, `event:` and comments are
|
|
497
|
+
# not read.
|
|
498
|
+
async def server_sent(lines: AsyncIterator[str]) -> AsyncIterator[JsonObject]:
|
|
499
|
+
"""Each JSON message of a server-sent event stream."""
|
|
500
|
+
data: list[str] = []
|
|
501
|
+
async for raw in lines:
|
|
502
|
+
line = raw.rstrip("\r\n")
|
|
503
|
+
if line.startswith("data:"):
|
|
504
|
+
data.append(line[5:].lstrip(" "))
|
|
505
|
+
elif not line and data:
|
|
506
|
+
text = json.loads("\n".join(data))
|
|
507
|
+
data = []
|
|
508
|
+
if isinstance(text, dict):
|
|
509
|
+
yield text
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def waits() -> Iterator[float]:
|
|
513
|
+
"""The waits between tries: doubling from half a second to five."""
|
|
514
|
+
wait = FIRST_WAIT_S
|
|
515
|
+
while True:
|
|
516
|
+
yield wait
|
|
517
|
+
wait = min(wait * 2, LONGEST_WAIT_S)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def away(refused: GatewayRefused) -> bool:
|
|
521
|
+
"""Whether the gateway is away (unreachable or a 5xx), which a retry outlasts."""
|
|
522
|
+
return refused.answered is None or refused.answered >= httpx.codes.INTERNAL_SERVER_ERROR
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# A 4xx is an answer. `within_s` bounds a request that has a deadline (a tool); None retries for
|
|
526
|
+
# as long as the call lasts (an entry).
|
|
527
|
+
async def again[T](attempt: Callable[[], Awaitable[T]], within_s: float | None, what: str) -> T:
|
|
528
|
+
"""Run the attempt, retrying while the gateway is away and there is time left."""
|
|
529
|
+
started = time.monotonic()
|
|
530
|
+
pauses = waits()
|
|
531
|
+
tries = 0
|
|
532
|
+
while True:
|
|
533
|
+
tries += 1
|
|
534
|
+
try:
|
|
535
|
+
return await attempt()
|
|
536
|
+
except GatewayRefused as refused:
|
|
537
|
+
pause = next(pauses)
|
|
538
|
+
late = within_s is not None and time.monotonic() - started + pause > within_s
|
|
539
|
+
if not away(refused) or late:
|
|
540
|
+
raise
|
|
541
|
+
if tries % SAID_EVERY == 0:
|
|
542
|
+
logger.warning("%s: the gateway is still away after %d tries", what, tries)
|
|
543
|
+
await asyncio.sleep(pause)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# The worker's current span rides each request as W3C traceparent, so the gateway's handling of an
|
|
547
|
+
# append joins the turn's trace; with no tracer set (no PINECALL_OTLP_ENDPOINT) nothing is added.
|
|
548
|
+
def traced() -> dict[str, str]:
|
|
549
|
+
"""The headers that carry the current trace to the gateway; none when nothing traces."""
|
|
550
|
+
carried: dict[str, str] = {}
|
|
551
|
+
propagate.inject(carried)
|
|
552
|
+
return carried
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# A box of several gateways hashes this header at its balancer, so a call's doors land on one
|
|
556
|
+
# gateway while it lives: its first sights, its sequencer's fills and its writers stay rare. Only
|
|
557
|
+
# a speed-up: any gateway serves any door, and an old worker sends none.
|
|
558
|
+
def affine(path: str) -> dict[str, str]:
|
|
559
|
+
"""The header naming the call a door is about, when its path names one."""
|
|
560
|
+
parts = path.split("/")
|
|
561
|
+
if len(parts) > CALL_AT and parts[1:CALL_AT] == ["v1", "calls"] and parts[CALL_AT]:
|
|
562
|
+
return {CALL_HEADER: parts[CALL_AT]}
|
|
563
|
+
return {}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _scope_headers(scope: Scope | None) -> dict[str, str]:
|
|
567
|
+
if scope is None:
|
|
568
|
+
return {}
|
|
569
|
+
return {"org": scope.org, "env": scope.env, "holder": scope.holder}
|
pinecall/fleet/demand.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""How many scaled workers a fleet wants: the one rule Kubernetes sizes the burst by."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from fractions import Fraction
|
|
7
|
+
|
|
8
|
+
from pinecall.fleet.roster import heard_lately
|
|
9
|
+
from pinecall.wire.rest.fleet import WorkerStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Line:
|
|
14
|
+
"""Where a fleet is kept: the busy target, the slack under it, how many, and their seats."""
|
|
15
|
+
|
|
16
|
+
# Grow above `target`; shrink only below `target - slack`, so two asks do not oscillate.
|
|
17
|
+
target: float = 0.6
|
|
18
|
+
slack: float = 0.15
|
|
19
|
+
at_least: int = 0
|
|
20
|
+
at_most: int = 10
|
|
21
|
+
# PINECALL_MAX_JOBS of a scaled worker: what one more of them adds.
|
|
22
|
+
seats_per_worker: int = 4
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# A cluster's workers that scale (their names start with `scaled`) are sized by Kubernetes, which
|
|
26
|
+
# asks this number, counting the seats of the workers that do not scale (a core node's) first, so
|
|
27
|
+
# a call they hold asks for no scaled one. Grow to what brings busy under the target; let one go
|
|
28
|
+
# only when busy stays under target - slack without it; else hold. Absolute, so a pod still
|
|
29
|
+
# booting, not yet heard, is never asked for twice.
|
|
30
|
+
def wanted_scaled(
|
|
31
|
+
seats: Sequence[WorkerStatus], scaled: str, line: Line, now: float
|
|
32
|
+
) -> tuple[int, int, int]:
|
|
33
|
+
"""How many scaled workers the fleet wants now, its calls, and the seats it holds."""
|
|
34
|
+
counted = [seat for seat in seats if heard_lately(seat, now) and seat.max_jobs is not None]
|
|
35
|
+
holding = [seat for seat in counted if not seat.cordoned]
|
|
36
|
+
active = sum(seat.active for seat in counted)
|
|
37
|
+
fixed = sum(seat.max_jobs or 0 for seat in holding if not seat.worker.startswith(scaled))
|
|
38
|
+
current = sum(1 for seat in holding if seat.worker.startswith(scaled))
|
|
39
|
+
each = line.seats_per_worker
|
|
40
|
+
# The target's decimal as written: 6 / 0.6 in floats is 10.000000000000002, one seat too many.
|
|
41
|
+
needed = math.ceil(Fraction(active) / Fraction(str(line.target))) - fixed
|
|
42
|
+
grown = max(0, math.ceil(Fraction(needed) / each)) if needed > 0 else 0
|
|
43
|
+
capacity = fixed + current * each
|
|
44
|
+
if grown > current:
|
|
45
|
+
return min(grown, line.at_most), active, capacity
|
|
46
|
+
left = fixed + (current - 1) * each
|
|
47
|
+
# With no call one may always go; with calls, only if the seats left keep busy under the slack.
|
|
48
|
+
quiet = active == 0 or (left > 0 and active / left < line.target - line.slack)
|
|
49
|
+
if current > max(grown, line.at_least) and quiet:
|
|
50
|
+
return current - 1, active, capacity
|
|
51
|
+
return current, active, capacity
|