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/main.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""pinecall-runtime: the operator's verbs, and the ones the cluster's pods and jobs run."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import FrameType
|
|
13
|
+
from typing import override
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import uvicorn
|
|
18
|
+
from livekit import api
|
|
19
|
+
|
|
20
|
+
from pinecall.channels import offers, whatsapp
|
|
21
|
+
from pinecall.cli import (
|
|
22
|
+
_archive,
|
|
23
|
+
_drift,
|
|
24
|
+
_facts,
|
|
25
|
+
_load,
|
|
26
|
+
_operator,
|
|
27
|
+
_sessions,
|
|
28
|
+
_traceback,
|
|
29
|
+
_usage,
|
|
30
|
+
)
|
|
31
|
+
from pinecall.domain.errors import NotAvailable, PinecallError
|
|
32
|
+
from pinecall.domain.names import DISTRIBUTION
|
|
33
|
+
from pinecall.gateway.app import announce_closing, app, embedder_of
|
|
34
|
+
from pinecall.log import days
|
|
35
|
+
from pinecall.postgres.migrate import (
|
|
36
|
+
apply_migrations,
|
|
37
|
+
granted,
|
|
38
|
+
migration_files,
|
|
39
|
+
migrations_behind,
|
|
40
|
+
)
|
|
41
|
+
from pinecall.postgres.pool import open_pool
|
|
42
|
+
from pinecall.process.connections import closed, keyring_of, opened, servers_of, vault_of
|
|
43
|
+
from pinecall.process.recordings import recordings_of
|
|
44
|
+
from pinecall.process.settings import Settings, load
|
|
45
|
+
from pinecall.providers import catalog, prices
|
|
46
|
+
from pinecall.providers.build import installed
|
|
47
|
+
from pinecall.providers.catalog import Providers
|
|
48
|
+
from pinecall.retrieval import memory
|
|
49
|
+
from pinecall.runner import main as runner
|
|
50
|
+
from pinecall.tenancy import retention, vault
|
|
51
|
+
from pinecall.worker import main as worker
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
NOT_LOOPBACK = (
|
|
60
|
+
"PINECALL_GATEWAY_URL binds {host}: a box's gateway listens on loopback, behind Caddy "
|
|
61
|
+
"(a pod's says PINECALL_GATEWAY_LISTEN)"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Google's load balancer keeps an idle connection to a gateway 600 s and sends the next request
|
|
66
|
+
# down it: the gateway must keep it longer (Google says 620 s), or a request meets a closed socket
|
|
67
|
+
# and the caller a 503.
|
|
68
|
+
KEEP_ALIVE_S = 620
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# A stream never ends on its own: told nothing, a stop waits out uvicorn's grace and cuts it.
|
|
72
|
+
class Stopping(uvicorn.Server):
|
|
73
|
+
"""uvicorn's server, which tells the open streams when it is told to stop."""
|
|
74
|
+
|
|
75
|
+
@override
|
|
76
|
+
def handle_exit(self, sig: int, frame: FrameType | None) -> None:
|
|
77
|
+
"""Tell the streams, then stop as uvicorn does."""
|
|
78
|
+
announce_closing(app)
|
|
79
|
+
super().handle_exit(sig, frame)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
83
|
+
"""Parse the verb and run it; its exit is the process's."""
|
|
84
|
+
data = verbs().parse_args(argv)
|
|
85
|
+
settings = load()
|
|
86
|
+
logging.basicConfig(
|
|
87
|
+
level=settings.log_level.upper(), format="%(levelname)s %(name)s: %(message)s"
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
sys.exit(data.run(settings, data))
|
|
91
|
+
except PinecallError as refused:
|
|
92
|
+
sys.stderr.write(f"pinecall-runtime: {refused}\n")
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def gateway(settings: Settings, _args: argparse.Namespace) -> int:
|
|
97
|
+
"""Serve the gateway: on its URL's loopback on a box, on PINECALL_GATEWAY_LISTEN in a pod."""
|
|
98
|
+
host, port = _listening(settings)
|
|
99
|
+
# The one proxy in front is believed alone: Caddy on loopback, or a cluster's load balancer.
|
|
100
|
+
config = uvicorn.Config(
|
|
101
|
+
app,
|
|
102
|
+
host=host,
|
|
103
|
+
port=port,
|
|
104
|
+
proxy_headers=True,
|
|
105
|
+
forwarded_allow_ips=settings.trusted_proxies,
|
|
106
|
+
timeout_keep_alive=KEEP_ALIVE_S,
|
|
107
|
+
)
|
|
108
|
+
# Every plugin imported before the server listens: imported at the first door that lists the
|
|
109
|
+
# vendors (/metrics, /v1/providers) instead, the 45 held the loop 5 s while requests waited
|
|
110
|
+
# (production, 2026-10-04).
|
|
111
|
+
installed()
|
|
112
|
+
Stopping(config).run()
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def start(settings: Settings, _args: argparse.Namespace) -> int:
|
|
117
|
+
"""A worker of the fleet, until told to stop or cordoned."""
|
|
118
|
+
return asyncio.run(worker.run(settings))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def overflow(settings: Settings, _args: argparse.Namespace) -> int:
|
|
122
|
+
"""The fleet's overflow, until told to stop."""
|
|
123
|
+
return asyncio.run(worker.overflow(settings))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_apps(settings: Settings, _args: argparse.Namespace) -> int:
|
|
127
|
+
"""The world's hosted apps, kept running until told to stop."""
|
|
128
|
+
return asyncio.run(runner.run(settings))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def migrate_up(settings: Settings, _args: argparse.Namespace) -> int:
|
|
132
|
+
"""Apply the migrations the database lacks, and say which."""
|
|
133
|
+
applied = asyncio.run(apply_migrations(settings.database_url))
|
|
134
|
+
names = ", ".join(applied.applied) or "nothing: it was up to date"
|
|
135
|
+
sys.stdout.write(f"{applied.database}/{applied.schema}: {names}\n")
|
|
136
|
+
if settings.db_app_role:
|
|
137
|
+
asyncio.run(granted(settings.database_url, settings.db_app_role))
|
|
138
|
+
sys.stdout.write(f"{settings.db_app_role}: every table's rows, no table's shape\n")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def migrate_status(settings: Settings, _args: argparse.Namespace) -> int:
|
|
143
|
+
"""What the database has not run yet; the exit is 1 while it is behind."""
|
|
144
|
+
behind = asyncio.run(_behind(settings))
|
|
145
|
+
if not behind:
|
|
146
|
+
sys.stdout.write("up to date\n")
|
|
147
|
+
return 0
|
|
148
|
+
sys.stdout.write(f"{len(behind)} behind: {', '.join(behind)}\n")
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def migrate_plan(_settings: Settings, _args: argparse.Namespace) -> int:
|
|
153
|
+
"""Every migration on the disk, in the order a run applies them; no database asked."""
|
|
154
|
+
for path in migration_files():
|
|
155
|
+
sys.stdout.write(f"{path.name}\n")
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def providers(settings: Settings, args: argparse.Namespace) -> int:
|
|
160
|
+
"""Every vendor this build runs, what it does, and whether the box holds its key."""
|
|
161
|
+
with_a_key = asyncio.run(_box_vendors(settings))
|
|
162
|
+
rows = sorted(installed().values(), key=lambda vendor: vendor.name)
|
|
163
|
+
for vendor in rows:
|
|
164
|
+
does = sorted(vendor.does)
|
|
165
|
+
if args.does is not None and args.does not in does:
|
|
166
|
+
continue
|
|
167
|
+
state = "broken" if vendor.broken else ("ready" if vendor.name in with_a_key else "no key")
|
|
168
|
+
sys.stdout.write(f"{vendor.name:16} {','.join(does):12} {state}\n")
|
|
169
|
+
sys.stdout.write(f"{len(rows)} vendors\n")
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Once: a box that has a row is edited from the console, and a second seed is refused.
|
|
174
|
+
def providers_seed(settings: Settings, args: argparse.Namespace) -> int:
|
|
175
|
+
"""Write the providers row a box starts from, off a JSON file."""
|
|
176
|
+
seeded = Providers.model_validate_json(Path(args.file).read_text(encoding="utf-8"))
|
|
177
|
+
asyncio.run(_seeded(settings, seeded))
|
|
178
|
+
sys.stdout.write("the providers row is written: the console edits it from now on\n")
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Shown by default: a file written by mistake replaces what the console set.
|
|
183
|
+
def providers_prices(settings: Settings, args: argparse.Namespace) -> int:
|
|
184
|
+
"""What a prices file changes in the box's rates, and with --apply, the rates written."""
|
|
185
|
+
written = prices.rates_from_csv(Path(args.file).read_text(encoding="utf-8"))
|
|
186
|
+
box = asyncio.run(_box_providers(settings))
|
|
187
|
+
change = prices.rates_changed(box.rates, written)
|
|
188
|
+
if args.apply:
|
|
189
|
+
rates = {**box.rates, **written}
|
|
190
|
+
asyncio.run(_configured(settings, box.model_copy(update={"rates": rates})))
|
|
191
|
+
for mark, models in (("+", change.added), ("~", change.changed)):
|
|
192
|
+
sys.stdout.writelines(f"{mark} {model}\n" for model in models)
|
|
193
|
+
sys.stdout.write(
|
|
194
|
+
f"{len(change.added)} new, {len(change.changed)} changed, {len(change.unchanged)} the "
|
|
195
|
+
f"same, {len(change.only_on_the_box)} only on the box and kept\n"
|
|
196
|
+
)
|
|
197
|
+
done = "written" if args.apply else "nothing written: --apply writes them"
|
|
198
|
+
sys.stdout.write(f"{done}\n")
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def memory_reembed(settings: Settings, _args: argparse.Namespace) -> int:
|
|
203
|
+
"""Every fact another model embedded, embedded again by the box's; how many there were."""
|
|
204
|
+
count = asyncio.run(_reembedded(settings))
|
|
205
|
+
sys.stdout.write(f"{count} facts re-embedded\n")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def retention_due(settings: Settings, _args: argparse.Namespace) -> int:
|
|
210
|
+
"""The sealed calls the next run would erase, one line each, and how many."""
|
|
211
|
+
calls = asyncio.run(_due(settings))
|
|
212
|
+
for call in calls:
|
|
213
|
+
sys.stdout.write(f"{call.call} {call.scope.org} {call.scope.env}\n")
|
|
214
|
+
sys.stdout.write(f"{len(calls)} calls past their org's days\n")
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def retention_run(settings: Settings, _args: argparse.Namespace) -> int:
|
|
219
|
+
"""Erase every sealed call past its org's days, forget what is kept for a time; how many."""
|
|
220
|
+
erased, records, dials, seen, kept = asyncio.run(_purged(settings))
|
|
221
|
+
sys.stdout.write(f"{len(erased)} calls erased past their org's days\n")
|
|
222
|
+
sys.stdout.write(f"{records} call records and {dials} dials forgotten past 24 months\n")
|
|
223
|
+
sys.stdout.write(f"{seen} WhatsApp message ids forgotten past Meta's 7 days of retries\n")
|
|
224
|
+
sys.stdout.write(
|
|
225
|
+
f"days of the log: {len(kept.made)} made ahead, {len(kept.dropped)} dropped once empty\n"
|
|
226
|
+
)
|
|
227
|
+
for day in kept.refused:
|
|
228
|
+
sys.stdout.write(f"{day} not made: {days.DEFAULT} holds rows of it\n")
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# Nothing is re-sealed that no key opens: that row is named, and the old key stays until it is 0.
|
|
233
|
+
def vault_rotate(settings: Settings, _args: argparse.Namespace) -> int:
|
|
234
|
+
"""Every sealed value re-sealed under PINECALL_VAULT_KEY's first key; 1 while one opens not."""
|
|
235
|
+
columns = asyncio.run(_rotated(settings))
|
|
236
|
+
for column in columns:
|
|
237
|
+
sys.stdout.write(
|
|
238
|
+
f"{column.column.named}: {column.resealed} re-sealed, {column.current} under the "
|
|
239
|
+
f"first key already, {column.unopened} opened by no key listed\n"
|
|
240
|
+
)
|
|
241
|
+
return 0 if all(column.unopened == 0 for column in columns) else 1
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def doctor(settings: Settings, _args: argparse.Namespace) -> int:
|
|
245
|
+
"""Each thing the box needs, a line each; the exit is 1 when one is missing."""
|
|
246
|
+
lines = asyncio.run(_examined(settings))
|
|
247
|
+
for name, trouble, state in lines:
|
|
248
|
+
text = "ok" if trouble is None else "NO"
|
|
249
|
+
why = f": {trouble}" if trouble is not None else (f": {state}" if state else "")
|
|
250
|
+
sys.stdout.write(f"{text} {name}{why}\n")
|
|
251
|
+
return 0 if all(trouble is None for _, trouble, _ in lines) else 1
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def verbs() -> argparse.ArgumentParser:
|
|
255
|
+
"""The parser of every group and verb, each bound to the function that runs it."""
|
|
256
|
+
verbs = argparse.ArgumentParser(prog="pinecall-runtime")
|
|
257
|
+
verbs.add_argument("--version", action="version", version=version(DISTRIBUTION))
|
|
258
|
+
under = verbs.add_subparsers(required=True)
|
|
259
|
+
under.add_parser("gateway", help="the gateway, both worlds").set_defaults(run=gateway)
|
|
260
|
+
worker_verbs = under.add_parser("worker", help="a worker of the fleet PINECALL_FLEET names")
|
|
261
|
+
of_worker = worker_verbs.add_subparsers(required=True)
|
|
262
|
+
of_worker.add_parser("start", help="answer the fleet's calls").set_defaults(run=start)
|
|
263
|
+
of_worker.add_parser("overflow", help="answer when the fleet is full").set_defaults(
|
|
264
|
+
run=overflow
|
|
265
|
+
)
|
|
266
|
+
runner_verbs = under.add_parser(
|
|
267
|
+
"runner", help="the runner of the world PINECALL_RUNNER_KEY opens"
|
|
268
|
+
)
|
|
269
|
+
of_runner = runner_verbs.add_subparsers(required=True)
|
|
270
|
+
of_runner.add_parser("start", help="keep the world's hosted apps running").set_defaults(
|
|
271
|
+
run=run_apps
|
|
272
|
+
)
|
|
273
|
+
migrate = under.add_parser("migrate", help="the schema").add_subparsers(required=True)
|
|
274
|
+
migrate.add_parser("up", help="apply what the database lacks").set_defaults(run=migrate_up)
|
|
275
|
+
migrate.add_parser("status", help="what the database lacks").set_defaults(run=migrate_status)
|
|
276
|
+
migrate.add_parser("plan", help="every migration, off the disk").set_defaults(run=migrate_plan)
|
|
277
|
+
_operator.keys_group(under.add_parser("keys", help="keys"))
|
|
278
|
+
under.add_parser("doctor", help="what this box lacks").set_defaults(run=doctor)
|
|
279
|
+
vault_verbs = under.add_parser("vault", help="the key every secret is sealed under")
|
|
280
|
+
vault_verbs.add_subparsers(required=True).add_parser(
|
|
281
|
+
"rotate", help="every sealed value re-sealed under the first key"
|
|
282
|
+
).set_defaults(run=vault_rotate)
|
|
283
|
+
vendors = under.add_parser(
|
|
284
|
+
"providers", help="the vendors this build runs, and the row"
|
|
285
|
+
).add_subparsers(required=True)
|
|
286
|
+
listing = vendors.add_parser("list", help="every vendor this build runs")
|
|
287
|
+
listing.add_argument("--does", choices=("llm", "stt", "tts"), default=None)
|
|
288
|
+
listing.set_defaults(run=providers)
|
|
289
|
+
seed = vendors.add_parser("seed", help="the providers row a box starts from, once")
|
|
290
|
+
seed.add_argument("file", help="a JSON file of the row")
|
|
291
|
+
seed.set_defaults(run=providers_seed)
|
|
292
|
+
pricing = vendors.add_parser("prices", help="the rates a prices file sets, shown or written")
|
|
293
|
+
pricing.add_argument("file", help="a CSV: vendor,model,unit,usd,as_of,source")
|
|
294
|
+
pricing.add_argument("--apply", action="store_true", help="write them into the box's row")
|
|
295
|
+
pricing.set_defaults(run=providers_prices)
|
|
296
|
+
memory_verbs = under.add_parser("memory", help="contact memory").add_subparsers(required=True)
|
|
297
|
+
memory_verbs.add_parser("reembed", help="every fact under the box's embedder").set_defaults(
|
|
298
|
+
run=memory_reembed
|
|
299
|
+
)
|
|
300
|
+
kept = under.add_parser("retention", help="the calls past their org's days")
|
|
301
|
+
kept_verbs = kept.add_subparsers(required=True)
|
|
302
|
+
kept_verbs.add_parser("due", help="what the next run would erase").set_defaults(
|
|
303
|
+
run=retention_due
|
|
304
|
+
)
|
|
305
|
+
kept_verbs.add_parser("run", help="erase them; a CronJob runs it nightly").set_defaults(
|
|
306
|
+
run=retention_run
|
|
307
|
+
)
|
|
308
|
+
_sessions.sessions_group(under.add_parser("sessions", help="the log, off Postgres"))
|
|
309
|
+
_facts.facts_group(under.add_parser("facts", help="each call's facts, folded from its log"))
|
|
310
|
+
_drift.drift_group(under.add_parser("drift", help="each day's drift, counted at the seal"))
|
|
311
|
+
_usage.usage_group(under.add_parser("usage", help="each org's usage totals, from its log"))
|
|
312
|
+
_operator.fence_group(under.add_parser("fence", help="the networks 5060 opens to"))
|
|
313
|
+
_operator.sip_group(under.add_parser("sip", help="where the carriers send each world's calls"))
|
|
314
|
+
_traceback.traceback_verb(
|
|
315
|
+
under.add_parser("traceback", help="a number's calls and dials, for a carrier")
|
|
316
|
+
)
|
|
317
|
+
_operator.init_group(under.add_parser("init", help="the first org and person, on a fresh box"))
|
|
318
|
+
_operator.orgs_group(under.add_parser("orgs", help="the tenants"))
|
|
319
|
+
_operator.routes_group(under.add_parser("routes", help="which agent answers a number"))
|
|
320
|
+
_operator.fleet_group(under.add_parser("fleet", help="the workers heard from"))
|
|
321
|
+
_load.load_verb(
|
|
322
|
+
under.add_parser("load", help="synthetic calls held against a sandbox, and measured")
|
|
323
|
+
)
|
|
324
|
+
return verbs
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# A box's gateway binds loopback and nothing else, Caddy being its one door; a pod's binds what
|
|
328
|
+
# PINECALL_GATEWAY_LISTEN names, the cluster's load balancer being its door.
|
|
329
|
+
def _listening(settings: Settings) -> tuple[str, int]:
|
|
330
|
+
if settings.gateway_listen is not None:
|
|
331
|
+
host, _, port = settings.gateway_listen.rpartition(":")
|
|
332
|
+
return host.strip("[]"), int(port)
|
|
333
|
+
bound = urlparse(settings.gateway_url)
|
|
334
|
+
host, port = bound.hostname or "127.0.0.1", bound.port or 8080
|
|
335
|
+
if host not in LOOPBACK:
|
|
336
|
+
raise PinecallError(NOT_LOOPBACK.format(host=host))
|
|
337
|
+
return host, port
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
async def _behind(settings: Settings) -> tuple[str, ...]:
|
|
341
|
+
pool = await open_pool(settings.database_url)
|
|
342
|
+
try:
|
|
343
|
+
return await migrations_behind(pool)
|
|
344
|
+
finally:
|
|
345
|
+
await pool.close()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def _box_vendors(settings: Settings) -> frozenset[str]:
|
|
349
|
+
pool = await open_pool(settings.database_url)
|
|
350
|
+
try:
|
|
351
|
+
return frozenset(await vault.box_credentials(pool, vault_of(settings.vault_key)))
|
|
352
|
+
finally:
|
|
353
|
+
await pool.close()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
async def _seeded(settings: Settings, seeded: Providers) -> None:
|
|
357
|
+
pool = await open_pool(settings.database_url)
|
|
358
|
+
try:
|
|
359
|
+
await catalog.seed(pool, seeded)
|
|
360
|
+
finally:
|
|
361
|
+
await pool.close()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def _box_providers(settings: Settings) -> Providers:
|
|
365
|
+
pool = await open_pool(settings.database_url)
|
|
366
|
+
try:
|
|
367
|
+
return await catalog.providers(pool)
|
|
368
|
+
finally:
|
|
369
|
+
await pool.close()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def _configured(settings: Settings, edited: Providers) -> None:
|
|
373
|
+
pool = await open_pool(settings.database_url)
|
|
374
|
+
try:
|
|
375
|
+
await catalog.configure(pool, edited)
|
|
376
|
+
finally:
|
|
377
|
+
await pool.close()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
async def _reembedded(settings: Settings) -> int:
|
|
381
|
+
async with opened(settings) as connections:
|
|
382
|
+
embedder = await embedder_of(connections)
|
|
383
|
+
if embedder is None:
|
|
384
|
+
raise NotAvailable("this box embeds nothing: no embedding in its providers row")
|
|
385
|
+
return await memory.reembed(connections.pool, embedder)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# The database and the vault's keys: no LiveKit, no HTTP.
|
|
389
|
+
async def _rotated(settings: Settings) -> list[vault.Resealing]:
|
|
390
|
+
keyring = keyring_of(settings.vault_key)
|
|
391
|
+
pool = await open_pool(settings.database_url)
|
|
392
|
+
try:
|
|
393
|
+
return await vault.resealed(pool, keyring)
|
|
394
|
+
finally:
|
|
395
|
+
await pool.close()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# The database alone: no vault, no LiveKit, so the nightly unit is handed nothing else.
|
|
399
|
+
async def _due(settings: Settings) -> list[retention.Due]:
|
|
400
|
+
pool = await open_pool(settings.database_url)
|
|
401
|
+
try:
|
|
402
|
+
return await retention.due(pool, time.time())
|
|
403
|
+
finally:
|
|
404
|
+
await pool.close()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
async def _purged(settings: Settings) -> tuple[list[str], int, int, int, days.Kept]:
|
|
408
|
+
pool = await open_pool(settings.database_url)
|
|
409
|
+
now = time.time()
|
|
410
|
+
try:
|
|
411
|
+
async with httpx.AsyncClient() as http:
|
|
412
|
+
erased = await retention.purge(pool, recordings_of(settings, http), now)
|
|
413
|
+
records = await retention.forget_records(pool, now)
|
|
414
|
+
dials = await retention.forget_dials(pool, now)
|
|
415
|
+
seen = await whatsapp.forget_seen(pool, now)
|
|
416
|
+
return erased, records, dials, seen, await days.kept(pool, now)
|
|
417
|
+
finally:
|
|
418
|
+
await pool.close()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def _examined(settings: Settings) -> list[tuple[str, str | None, str]]:
|
|
422
|
+
return [
|
|
423
|
+
("vault", _vault(settings), ""),
|
|
424
|
+
("database", await _database(settings), ""),
|
|
425
|
+
("facts", await _facts.examined(settings), ""),
|
|
426
|
+
("days", await _days(settings), ""),
|
|
427
|
+
("offers", await _offers(settings), ""),
|
|
428
|
+
("archive", *await _archived(settings)),
|
|
429
|
+
("livekit", await _livekit(settings), ""),
|
|
430
|
+
("gateway", await _gateway(settings), ""),
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def _days(settings: Settings) -> str | None:
|
|
435
|
+
try:
|
|
436
|
+
pool = await open_pool(settings.database_url)
|
|
437
|
+
except PinecallError as refused:
|
|
438
|
+
return str(refused)
|
|
439
|
+
try:
|
|
440
|
+
return await days.examined(pool, time.time())
|
|
441
|
+
finally:
|
|
442
|
+
await pool.close()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def _offers(settings: Settings) -> str | None:
|
|
446
|
+
try:
|
|
447
|
+
pool = await open_pool(settings.database_url)
|
|
448
|
+
except PinecallError as refused:
|
|
449
|
+
return str(refused)
|
|
450
|
+
try:
|
|
451
|
+
return await offers.examined(pool, time.time())
|
|
452
|
+
finally:
|
|
453
|
+
await pool.close()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _vault(settings: Settings) -> str | None:
|
|
457
|
+
try:
|
|
458
|
+
vault_of(settings.vault_key)
|
|
459
|
+
except PinecallError as refused:
|
|
460
|
+
return str(refused)
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
async def _database(settings: Settings) -> str | None:
|
|
465
|
+
try:
|
|
466
|
+
pool = await open_pool(settings.database_url)
|
|
467
|
+
except PinecallError as refused:
|
|
468
|
+
return str(refused)
|
|
469
|
+
try:
|
|
470
|
+
behind = await migrations_behind(pool)
|
|
471
|
+
finally:
|
|
472
|
+
await pool.close()
|
|
473
|
+
return f"{len(behind)} migrations behind: {', '.join(behind)}" if behind else None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
async def _archived(settings: Settings) -> tuple[str | None, str]:
|
|
477
|
+
try:
|
|
478
|
+
pool = await open_pool(settings.database_url)
|
|
479
|
+
except PinecallError as refused:
|
|
480
|
+
return str(refused), ""
|
|
481
|
+
try:
|
|
482
|
+
archive = await _archive.archive_of(pool, settings.wal_spool)
|
|
483
|
+
finally:
|
|
484
|
+
await pool.close()
|
|
485
|
+
return _archive.archive_finding(archive, datetime.now(UTC))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# Each world's LiveKit is asked, and the one that does not answer is named.
|
|
489
|
+
async def _livekit(settings: Settings) -> str | None:
|
|
490
|
+
try:
|
|
491
|
+
servers = servers_of(settings)
|
|
492
|
+
except PinecallError as refused:
|
|
493
|
+
return str(refused)
|
|
494
|
+
failed: list[str] = []
|
|
495
|
+
try:
|
|
496
|
+
for world, server in servers.items():
|
|
497
|
+
refused = await _refused_by(server)
|
|
498
|
+
if refused is not None:
|
|
499
|
+
failed.append(f"{world} ({settings.livekit_url_of(world)}): {refused}")
|
|
500
|
+
finally:
|
|
501
|
+
await closed(servers)
|
|
502
|
+
return "; ".join(failed) or None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
async def _refused_by(server: api.LiveKitAPI) -> str | None:
|
|
506
|
+
try:
|
|
507
|
+
await server.room.list_rooms(api.ListRoomsRequest())
|
|
508
|
+
except (api.TwirpError, OSError) as refused:
|
|
509
|
+
return str(refused)
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def _gateway(settings: Settings) -> str | None:
|
|
514
|
+
try:
|
|
515
|
+
async with httpx.AsyncClient(timeout=5.0) as http:
|
|
516
|
+
answer = await http.get(f"{settings.gateway_url}/v1/docs")
|
|
517
|
+
except httpx.HTTPError as unreachable:
|
|
518
|
+
return str(unreachable)
|
|
519
|
+
return (
|
|
520
|
+
None if answer.status_code < httpx.codes.BAD_REQUEST else f"answered {answer.status_code}"
|
|
521
|
+
)
|
|
File without changes
|