pinecall-runtime 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Written calls on the gateway: opened on the socket that holds the agent, or taken up again."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from datetime import UTC, date, datetime
|
|
6
|
+
|
|
7
|
+
from pinecall.domain.agent import AgentConfig, Versions
|
|
8
|
+
from pinecall.domain.call import CallContext, Route, today_in
|
|
9
|
+
from pinecall.domain.errors import Conflict
|
|
10
|
+
from pinecall.domain.names import CHANNELS_WITH_A_NUMBER, JsonObject
|
|
11
|
+
from pinecall.domain.scope import Scope
|
|
12
|
+
from pinecall.evals import goldens
|
|
13
|
+
from pinecall.gateway._call_setup import keys_of, tuned
|
|
14
|
+
from pinecall.gateway._served import Served, Serving
|
|
15
|
+
from pinecall.gateway._sockets import Registration
|
|
16
|
+
from pinecall.gateway.calls.binding import attach
|
|
17
|
+
from pinecall.gateway.calls.serving import looked_up, served_call
|
|
18
|
+
from pinecall.gateway.ending.seal import sealed
|
|
19
|
+
from pinecall.log import openings, private, queries
|
|
20
|
+
from pinecall.log.store import Claim
|
|
21
|
+
from pinecall.providers import catalog
|
|
22
|
+
from pinecall.providers.build import Running
|
|
23
|
+
from pinecall.providers.credentials import thinking
|
|
24
|
+
from pinecall.session import text
|
|
25
|
+
from pinecall.session.call import Call, Platform
|
|
26
|
+
from pinecall.session.session import Session
|
|
27
|
+
from pinecall.session.text import text_session
|
|
28
|
+
from pinecall.tenancy import admission
|
|
29
|
+
from pinecall.tenancy.scopes import Picked
|
|
30
|
+
from pinecall.wire.events import (
|
|
31
|
+
TERMINAL_EVENT,
|
|
32
|
+
CallStarted,
|
|
33
|
+
)
|
|
34
|
+
from pinecall.wire.frames import Entry
|
|
35
|
+
from pinecall.wire.metrics import LLMModelUsage, ModelUsage
|
|
36
|
+
from pinecall.wire.parts import PlatformTool
|
|
37
|
+
from pinecall.wire.rest.calls import LookupRequest, SealCallRequest
|
|
38
|
+
|
|
39
|
+
# A written call's session runs on one gateway, which says so: another takes it up only once that
|
|
40
|
+
# gateway fell silent.
|
|
41
|
+
RUNS_ELSEWHERE = "call {call} runs on another gateway: it is taken up there"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class TextSetup:
|
|
46
|
+
"""What a written call runs on: the tuned config, its versions, the model, a golden's facts."""
|
|
47
|
+
|
|
48
|
+
config: AgentConfig
|
|
49
|
+
versions: Versions
|
|
50
|
+
model: Running
|
|
51
|
+
# A golden's facts, which recall answers instead of the contact's memory; None on a real call.
|
|
52
|
+
recalled: tuple[str, ...] | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def open_text(serving: Serving, registration: Registration, context: CallContext) -> Session:
|
|
56
|
+
"""A new written call on the socket that holds the agent, admitted and unstarted."""
|
|
57
|
+
pool, scope = serving.connections.pool, registration.scope
|
|
58
|
+
configured = await catalog.providers(pool)
|
|
59
|
+
config, versions = await tuned(
|
|
60
|
+
pool, registration.config, scope, configured, Picked(call=context.call)
|
|
61
|
+
)
|
|
62
|
+
model = thinking(config, configured, await keys_of(pool, serving.connections.vault, scope))
|
|
63
|
+
return await open_text_as(serving, registration, context, TextSetup(config, versions, model))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Admission before the log is claimed: a refused call leaves nothing behind.
|
|
67
|
+
async def open_text_as(
|
|
68
|
+
serving: Serving, registration: Registration, context: CallContext, setup: TextSetup
|
|
69
|
+
) -> Session:
|
|
70
|
+
"""A new written call on this setup, admitted and unstarted."""
|
|
71
|
+
pool, scope = serving.connections.pool, registration.scope
|
|
72
|
+
await admission.admit_call(
|
|
73
|
+
pool,
|
|
74
|
+
scope.org,
|
|
75
|
+
scope.env,
|
|
76
|
+
running=serving.live.running(scope.org, scope.env),
|
|
77
|
+
at=serving.logs.store.clock(),
|
|
78
|
+
)
|
|
79
|
+
await serving.logs.store.claim(
|
|
80
|
+
context.call,
|
|
81
|
+
registration.slug,
|
|
82
|
+
scope.org,
|
|
83
|
+
Claim(scope, setup.versions, opened_by="gateway"),
|
|
84
|
+
)
|
|
85
|
+
# Kept as a phone call's is: the gateway holding the app's socket serves it from this.
|
|
86
|
+
await openings.kept(pool, scope.org, context, setup.config)
|
|
87
|
+
await serving.prompts.keep(pool, scope.org, setup.config.knowledge or "")
|
|
88
|
+
served = served_call(serving, registration.owner, context, setup.config, scope)
|
|
89
|
+
serving.live.opened_by(context.call, "gateway")
|
|
90
|
+
await serving.live.commands_heard(context.call)
|
|
91
|
+
return _session(serving, served, setup.model, setup.recalled)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# A gateway that restarted forgot the call, not the caller: no admission, no second greeting.
|
|
95
|
+
async def resume_text(
|
|
96
|
+
serving: Serving, registration: Registration, call: str, zone: str
|
|
97
|
+
) -> Session | None:
|
|
98
|
+
"""A written call taken up again from its log; None for one that is over or not this agent's."""
|
|
99
|
+
kept = await queries.scope_of_call(serving.connections.pool, call)
|
|
100
|
+
if kept is None or kept.sealed or kept.scope is None or kept.agent != registration.slug:
|
|
101
|
+
return None
|
|
102
|
+
if serving.live.owners.text_elsewhere(call):
|
|
103
|
+
raise Conflict(RUNS_ELSEWHERE.format(call=call))
|
|
104
|
+
if kept.scope.org != registration.scope.org:
|
|
105
|
+
return None
|
|
106
|
+
# The model reads the tools' arguments as they were sent: the private ones opened.
|
|
107
|
+
entries = await private.opened_whole(serving.logs.store, serving.connections.vault, call)
|
|
108
|
+
if not entries or any(entry.type == TERMINAL_EVENT for entry in entries):
|
|
109
|
+
return None
|
|
110
|
+
configured = await catalog.providers(serving.connections.pool)
|
|
111
|
+
config, _ = await tuned(
|
|
112
|
+
serving.connections.pool, registration.config, kept.scope, configured, Picked(call=call)
|
|
113
|
+
)
|
|
114
|
+
model = thinking(
|
|
115
|
+
config,
|
|
116
|
+
configured,
|
|
117
|
+
await keys_of(serving.connections.pool, serving.connections.vault, kept.scope),
|
|
118
|
+
)
|
|
119
|
+
context = _as_it_opened(call, registration, kept.scope, entries, today_in(zone))
|
|
120
|
+
served = served_call(serving, None, context, config, kept.scope)
|
|
121
|
+
serving.live.opened_by(call, kept.opened_by)
|
|
122
|
+
await serving.live.commands_heard(call)
|
|
123
|
+
session = _session(serving, served, model, None)
|
|
124
|
+
# The session's batches go on from what the log already took from it, never from zero.
|
|
125
|
+
session.call.writing.after = await serving.logs.store.written(call)
|
|
126
|
+
await text.resume(session, text.taken_up(entries))
|
|
127
|
+
await attach(serving.live, call, registration.owner)
|
|
128
|
+
return session
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def tokens_of(usage: Iterable[ModelUsage]) -> int:
|
|
132
|
+
"""The model tokens a call used so far: the llm_tokens quota counts these."""
|
|
133
|
+
return sum(
|
|
134
|
+
(used.input_tokens or 0) + (used.output_tokens or 0)
|
|
135
|
+
for used in usage
|
|
136
|
+
if isinstance(used, LLMModelUsage)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _session(
|
|
141
|
+
serving: Serving, served: Served, model: Running, recalled: tuple[str, ...] | None
|
|
142
|
+
) -> Session:
|
|
143
|
+
async def lookup(tool: PlatformTool, arguments: JsonObject, speech: str | None) -> JsonObject:
|
|
144
|
+
request = LookupRequest(tool=tool, input=arguments, speech_id=speech)
|
|
145
|
+
return await looked_up(serving, served, request)
|
|
146
|
+
|
|
147
|
+
async def seal(usage: list[ModelUsage], outcome: str) -> None:
|
|
148
|
+
await sealed(serving, served, SealCallRequest(usage=usage, outcome=outcome))
|
|
149
|
+
|
|
150
|
+
looking = lookup if recalled is None else goldens.golden_lookup(recalled, lookup)
|
|
151
|
+
platform = Platform(
|
|
152
|
+
append_many=served.log.append_many, tool=served.tools.ran, lookup=looking, seal=seal
|
|
153
|
+
)
|
|
154
|
+
session = text_session(Call(served.context, served.config, platform), model)
|
|
155
|
+
serving.live.calls[served.call] = replace(served, session=session)
|
|
156
|
+
serving.live.owners.running(served.call, here=True)
|
|
157
|
+
return session
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _as_it_opened(
|
|
161
|
+
call: str, registration: Registration, scope: Scope, entries: Iterable[Entry], today: date
|
|
162
|
+
) -> CallContext:
|
|
163
|
+
started = next((entry for entry in entries if entry.type == "call.started"), None)
|
|
164
|
+
data = None if started is None else CallStarted.model_validate(started.data)
|
|
165
|
+
caller = "" if data is None else (data.from_ or "")
|
|
166
|
+
# A WhatsApp conversation comes back on its number; a chat at none.
|
|
167
|
+
channel = "web" if data is None else data.channel
|
|
168
|
+
number = data.to if data is not None and channel in CHANNELS_WITH_A_NUMBER else None
|
|
169
|
+
route = Route(
|
|
170
|
+
org=scope.org, agent=registration.slug, channel=channel, number=number, env=scope.env
|
|
171
|
+
)
|
|
172
|
+
if data is not None:
|
|
173
|
+
today = datetime.fromtimestamp(data.started_at, tz=UTC).date()
|
|
174
|
+
return CallContext(
|
|
175
|
+
call=call,
|
|
176
|
+
channel=channel,
|
|
177
|
+
direction="inbound",
|
|
178
|
+
caller=caller or call,
|
|
179
|
+
route=route,
|
|
180
|
+
today=today,
|
|
181
|
+
holder=scope.holder or None,
|
|
182
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Who a key is, and how a person gets one: sign-in, codes, the org switch, a paired terminal."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Request, Response
|
|
6
|
+
|
|
7
|
+
from pinecall.domain.errors import Conflict, DeclarationRefused, NotAllowed, NotFound, NotSignedIn
|
|
8
|
+
from pinecall.domain.names import DISTRIBUTION, PRODUCTION
|
|
9
|
+
from pinecall.domain.person import Member
|
|
10
|
+
from pinecall.gateway._deps import (
|
|
11
|
+
ActingDep,
|
|
12
|
+
BearerDep,
|
|
13
|
+
GatewayDep,
|
|
14
|
+
check_knock,
|
|
15
|
+
client_of,
|
|
16
|
+
public_url,
|
|
17
|
+
)
|
|
18
|
+
from pinecall.postgres.pool import box_wide
|
|
19
|
+
from pinecall.tenancy import keys, letters, mail, orgs, people, signin
|
|
20
|
+
from pinecall.tenancy.signin import Asking
|
|
21
|
+
from pinecall.wire.rest.accounts import (
|
|
22
|
+
AcceptInvitationRequest,
|
|
23
|
+
BrandRow,
|
|
24
|
+
EmptyResponse,
|
|
25
|
+
FirstKeyResponse,
|
|
26
|
+
ForgotPasswordRequest,
|
|
27
|
+
GatewayInfoResponse,
|
|
28
|
+
KeyCollectedResponse,
|
|
29
|
+
KeyIssuedResponse,
|
|
30
|
+
MemberRow,
|
|
31
|
+
MintCodeResponse,
|
|
32
|
+
OpenPairingRequest,
|
|
33
|
+
OrgOfPersonRow,
|
|
34
|
+
OrgsOfPersonResponse,
|
|
35
|
+
PairingApprovedResponse,
|
|
36
|
+
PairingStatusResponse,
|
|
37
|
+
SignInOrgRow,
|
|
38
|
+
SignInOrgsRequest,
|
|
39
|
+
SignInOrgsResponse,
|
|
40
|
+
SignInRequest,
|
|
41
|
+
SwitchOrgRequest,
|
|
42
|
+
WhoamiResponse,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
router = APIRouter()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ONE_OR_THE_OTHER = "log in with org, email and password, or with a code: one of the two"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# One sentence for a code spent, dead or never minted.
|
|
52
|
+
NO_CODE = "no code answers to that: it was used, it expired, or it never existed"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# One sentence for a word collected, dead or never printed, so nobody probes which existed.
|
|
56
|
+
NO_PAIRING = "no pairing answers to that: it was collected, it expired, or it never existed"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
ANSWERED = "that terminal is already signed in: it has a key waiting"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
NOT_A_PERSON = "an org's own key names nobody: a terminal is signed in as a person"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
NO_INVITATION = (
|
|
66
|
+
"no open invitation answers to that token: it was used, it expired, or it never existed"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# What a listed org says of a person of the box who is no member there.
|
|
71
|
+
AS_THE_OPERATOR = "operator"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
FIRST_KEY = "invitation"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/.well-known/pinecall")
|
|
78
|
+
async def gateway_info(gateway: GatewayDep) -> GatewayInfoResponse:
|
|
79
|
+
"""What this gateway is and how it signs people in, before anybody holds a key."""
|
|
80
|
+
connections = gateway.connections
|
|
81
|
+
box_mail = await mail.box_mail_of(
|
|
82
|
+
connections.pool, connections.vault, gateway.outbox.environment
|
|
83
|
+
)
|
|
84
|
+
brand = await letters.brand_of(connections.pool)
|
|
85
|
+
return GatewayInfoResponse(
|
|
86
|
+
version=version(DISTRIBUTION),
|
|
87
|
+
signup=connections.settings.signup,
|
|
88
|
+
min_password=connections.settings.min_password,
|
|
89
|
+
mail=box_mail is not None,
|
|
90
|
+
brand=BrandRow(name=brand.name, logo_url=brand.logo_url, accent=brand.accent),
|
|
91
|
+
google=False,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/v1/whoami")
|
|
96
|
+
async def whoami(key: ActingDep, gateway: GatewayDep) -> WhoamiResponse:
|
|
97
|
+
"""Whose key this is, the world this request acts in, and what the key opens."""
|
|
98
|
+
minted, member = key.bearer.key, key.bearer.member
|
|
99
|
+
org = await orgs.find(gateway.connections.pool, minted.org)
|
|
100
|
+
return WhoamiResponse(
|
|
101
|
+
org=minted.org,
|
|
102
|
+
slug=None if org is None else org.slug,
|
|
103
|
+
key_id=minted.key_id,
|
|
104
|
+
label=minted.label,
|
|
105
|
+
env=key.env,
|
|
106
|
+
scopes=sorted(minted.scopes),
|
|
107
|
+
subject=minted.subject,
|
|
108
|
+
name=minted.name,
|
|
109
|
+
email=None if member is None else member.email,
|
|
110
|
+
operator=member is not None and member.operator,
|
|
111
|
+
visiting=member is not None and member.org != minted.org,
|
|
112
|
+
production=minted.env == PRODUCTION if member is None else member.opens_production,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# The body says a password or a code; each is its own sign-in, and a body with both is refused.
|
|
117
|
+
@router.post("/v1/login")
|
|
118
|
+
async def sign_in(body: SignInRequest, request: Request, gateway: GatewayDep) -> KeyIssuedResponse:
|
|
119
|
+
"""A key for a person and a device, from a password, or from a one-use code."""
|
|
120
|
+
pool = gateway.connections.pool
|
|
121
|
+
if body.code is not None:
|
|
122
|
+
if body.org is not None or body.email is not None or body.password is not None:
|
|
123
|
+
raise DeclarationRefused(ONE_OR_THE_OTHER)
|
|
124
|
+
signed = await signin.sign_in_with_code(
|
|
125
|
+
pool, gateway.signins.codes, body.code, device=body.device
|
|
126
|
+
)
|
|
127
|
+
if signed is None:
|
|
128
|
+
raise NotFound(NO_CODE)
|
|
129
|
+
return KeyIssuedResponse.of(signed.key, signed.secret)
|
|
130
|
+
if body.email is None or body.password is None:
|
|
131
|
+
raise DeclarationRefused(ONE_OR_THE_OTHER)
|
|
132
|
+
knock = f"{client_of(request)} {body.org or '*'}/{body.email}"
|
|
133
|
+
await check_knock(gateway, knock, signin.TOO_MANY.format(email=body.email))
|
|
134
|
+
typed = Asking(body.email, body.password, body.org, body.device)
|
|
135
|
+
signed = await signin.sign_in_with_password(pool, typed)
|
|
136
|
+
return KeyIssuedResponse.of(signed.key, signed.secret)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Throttled as the login is, and refused in its sentence, so a wrong password says nothing more.
|
|
140
|
+
@router.post("/v1/login/orgs")
|
|
141
|
+
async def sign_in_orgs(
|
|
142
|
+
body: SignInOrgsRequest, request: Request, gateway: GatewayDep
|
|
143
|
+
) -> SignInOrgsResponse:
|
|
144
|
+
"""The orgs an address and a password open, minting nothing."""
|
|
145
|
+
pool = gateway.connections.pool
|
|
146
|
+
knock = f"{client_of(request)} */{body.email}"
|
|
147
|
+
await check_knock(gateway, knock, signin.TOO_MANY.format(email=body.email))
|
|
148
|
+
opened = await signin.orgs_signed_into(pool, body.email, body.password)
|
|
149
|
+
if not opened:
|
|
150
|
+
raise NotSignedIn(signin.NOBODY)
|
|
151
|
+
rows: list[SignInOrgRow] = []
|
|
152
|
+
for row in opened:
|
|
153
|
+
org = await orgs.find(pool, row.org)
|
|
154
|
+
if org is not None:
|
|
155
|
+
rows.append(SignInOrgRow(org=org.id, slug=org.slug, name=org.name, role=row.role))
|
|
156
|
+
return SignInOrgsResponse(orgs=rows)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# A person of the box also sees every org there is, to visit without taking a seat.
|
|
160
|
+
@router.get("/v1/login/orgs")
|
|
161
|
+
async def list_orgs_of_person(key: BearerDep, gateway: GatewayDep) -> OrgsOfPersonResponse:
|
|
162
|
+
"""Every org this key's person may open, oldest first, and which one this key opens."""
|
|
163
|
+
pool = gateway.connections.pool
|
|
164
|
+
person = _person_of(key.member)
|
|
165
|
+
rows: list[OrgOfPersonRow] = []
|
|
166
|
+
# The person's other orgs are other orgs: read past the key's own (postgres/pool.py).
|
|
167
|
+
with box_wide():
|
|
168
|
+
for row in await people.orgs_of(pool, person.email):
|
|
169
|
+
if row.status == "disabled":
|
|
170
|
+
continue
|
|
171
|
+
org = await orgs.find(pool, row.org)
|
|
172
|
+
rows.append(
|
|
173
|
+
OrgOfPersonRow(
|
|
174
|
+
org=row.org,
|
|
175
|
+
slug=None if org is None else org.slug,
|
|
176
|
+
name=None if org is None else org.name,
|
|
177
|
+
role=row.role,
|
|
178
|
+
status=row.status,
|
|
179
|
+
here=row.org == key.key.org,
|
|
180
|
+
member=True,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
if not person.operator:
|
|
184
|
+
return OrgsOfPersonResponse(orgs=rows)
|
|
185
|
+
theirs = {row.org for row in rows}
|
|
186
|
+
for org in await orgs.listed(pool):
|
|
187
|
+
if org.id in theirs:
|
|
188
|
+
continue
|
|
189
|
+
rows.append(
|
|
190
|
+
OrgOfPersonRow(
|
|
191
|
+
org=org.id,
|
|
192
|
+
slug=org.slug,
|
|
193
|
+
name=org.name,
|
|
194
|
+
role=AS_THE_OPERATOR,
|
|
195
|
+
status="active",
|
|
196
|
+
here=org.id == key.key.org,
|
|
197
|
+
member=False,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return OrgsOfPersonResponse(orgs=rows)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@router.post("/v1/login/org")
|
|
204
|
+
async def switch_org(
|
|
205
|
+
body: SwitchOrgRequest, key: BearerDep, gateway: GatewayDep
|
|
206
|
+
) -> KeyIssuedResponse:
|
|
207
|
+
"""The same person's key in another org of theirs, or a visit for a person of the box."""
|
|
208
|
+
pool = gateway.connections.pool
|
|
209
|
+
person = _person_of(key.member)
|
|
210
|
+
# The org asked for is another org, and the key minted is written in it.
|
|
211
|
+
with box_wide():
|
|
212
|
+
org = await orgs.find(pool, body.org)
|
|
213
|
+
if org is None:
|
|
214
|
+
raise NotAllowed(signin.NOT_THEIRS.format(org=body.org))
|
|
215
|
+
signed = await signin.key_in(pool, person, org.id, label=key.key.label)
|
|
216
|
+
return KeyIssuedResponse.of(signed.key, signed.secret)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# A browser signs in with the code, so a key never rides a URL.
|
|
220
|
+
@router.post("/v1/login/codes")
|
|
221
|
+
async def mint_code(key: BearerDep, gateway: GatewayDep) -> MintCodeResponse:
|
|
222
|
+
"""A one-use word that gives a browser a key like this one, for five minutes."""
|
|
223
|
+
code, expires_at = await gateway.signins.codes.mint(key.key)
|
|
224
|
+
return MintCodeResponse(code=code, expires_at=expires_at)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# 202 whoever asks, and throttled like the login, so the door says nothing about who exists.
|
|
228
|
+
@router.post("/v1/login/reset", status_code=202)
|
|
229
|
+
async def forget_password(
|
|
230
|
+
body: ForgotPasswordRequest, request: Request, gateway: GatewayDep
|
|
231
|
+
) -> EmptyResponse:
|
|
232
|
+
"""Mail a one-use link that sets the password, where one can go."""
|
|
233
|
+
knock = f"{client_of(request)} */{body.email}"
|
|
234
|
+
await check_knock(gateway, knock, signin.TOO_MANY.format(email=body.email))
|
|
235
|
+
await signin.forgotten(
|
|
236
|
+
gateway.connections.pool, gateway.outbox, body.email, public_url(request, gateway)
|
|
237
|
+
)
|
|
238
|
+
return EmptyResponse()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@router.post("/v1/login/pairings")
|
|
242
|
+
async def open_pairing(body: OpenPairingRequest, gateway: GatewayDep) -> MintCodeResponse:
|
|
243
|
+
"""The word a terminal prints for a browser to approve, and when it dies."""
|
|
244
|
+
code, expires_at = await gateway.signins.pairings.open(body.device)
|
|
245
|
+
return MintCodeResponse(code=code, expires_at=expires_at)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Reading it does not spend it, so the approving page can be reloaded.
|
|
249
|
+
@router.get("/v1/login/pairings/{code}")
|
|
250
|
+
async def pairing_status(code: str, gateway: GatewayDep) -> PairingStatusResponse:
|
|
251
|
+
"""Which terminal a browser is about to sign in, and whether it is answered."""
|
|
252
|
+
waiting = await gateway.signins.pairings.asking(code)
|
|
253
|
+
if waiting is None:
|
|
254
|
+
raise NotFound(NO_PAIRING)
|
|
255
|
+
return PairingStatusResponse(
|
|
256
|
+
device=waiting.device, expires_at=waiting.expires_at, answered=waiting.answered
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# The password is typed in the browser, never the terminal, so this works with SSO too; the
|
|
261
|
+
# terminal gets a key of its own, never the browser's.
|
|
262
|
+
@router.post("/v1/login/pairings/{code}")
|
|
263
|
+
async def approve_pairing(
|
|
264
|
+
code: str, key: BearerDep, gateway: GatewayDep
|
|
265
|
+
) -> PairingApprovedResponse:
|
|
266
|
+
"""Sign the terminal in as the person this browser is."""
|
|
267
|
+
if key.key.subject is None or key.member is None:
|
|
268
|
+
raise NotAllowed(NOT_A_PERSON)
|
|
269
|
+
pairings = gateway.signins.pairings
|
|
270
|
+
waiting = await pairings.asking(code)
|
|
271
|
+
if waiting is None:
|
|
272
|
+
raise NotFound(NO_PAIRING)
|
|
273
|
+
if waiting.answered:
|
|
274
|
+
raise Conflict(ANSWERED)
|
|
275
|
+
signed = await signin.another_key(
|
|
276
|
+
gateway.connections.pool, key.key, key.member, device=waiting.device
|
|
277
|
+
)
|
|
278
|
+
if not await pairings.fill(code, signed.secret, signed.key.org):
|
|
279
|
+
raise Conflict(ANSWERED)
|
|
280
|
+
org = await orgs.find(gateway.connections.pool, signed.key.org)
|
|
281
|
+
return PairingApprovedResponse(
|
|
282
|
+
device=waiting.device,
|
|
283
|
+
org=signed.key.org,
|
|
284
|
+
org_name=signed.key.org if org is None else org.name,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# 202 is "ask again"; 404 is a word gone, and an empty 200 would read as a key of "".
|
|
289
|
+
@router.get("/v1/login/pairings/{code}/key")
|
|
290
|
+
async def collect_key(
|
|
291
|
+
code: str, response: Response, gateway: GatewayDep
|
|
292
|
+
) -> KeyCollectedResponse | EmptyResponse:
|
|
293
|
+
"""The terminal's key, once; 202 while nobody has approved."""
|
|
294
|
+
collected = await gateway.signins.pairings.collect(code)
|
|
295
|
+
if collected.key is not None:
|
|
296
|
+
return KeyCollectedResponse(key=collected.key)
|
|
297
|
+
if not collected.waiting:
|
|
298
|
+
raise NotFound(NO_PAIRING)
|
|
299
|
+
response.status_code = 202
|
|
300
|
+
return EmptyResponse()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# No key: the week-long, one-use token is the credential. An invitation seats the person with the
|
|
304
|
+
# password they have, or their first one; a reset link sets it again.
|
|
305
|
+
@router.post("/v1/invitations/{token}")
|
|
306
|
+
async def accept_invitation(
|
|
307
|
+
token: str, body: AcceptInvitationRequest, gateway: GatewayDep
|
|
308
|
+
) -> FirstKeyResponse:
|
|
309
|
+
"""Type the password: the member is active, and here is their first key."""
|
|
310
|
+
pool = gateway.connections.pool
|
|
311
|
+
shortest = gateway.connections.settings.min_password
|
|
312
|
+
people.check_password(body.password, shortest)
|
|
313
|
+
member = await people.accept(pool, token, body.password, shortest)
|
|
314
|
+
if member is None:
|
|
315
|
+
raise NotFound(NO_INVITATION)
|
|
316
|
+
minted, secret = await keys.person_key(pool, member, label=body.device or FIRST_KEY)
|
|
317
|
+
issued = KeyIssuedResponse.of(minted, secret)
|
|
318
|
+
return FirstKeyResponse(**issued.model_dump(), member=MemberRow.of(member))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _person_of(member: Member | None) -> Member:
|
|
322
|
+
if member is None:
|
|
323
|
+
raise NotAllowed(signin.NOT_A_PERSONS)
|
|
324
|
+
return member
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""What a worker asks about an agent, and the agents and routes the org holds."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import time
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Query, Response
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from pinecall.channels import routes
|
|
11
|
+
from pinecall.domain.agent import AgentConfig
|
|
12
|
+
from pinecall.domain.call import Route
|
|
13
|
+
from pinecall.domain.errors import (
|
|
14
|
+
NotFound,
|
|
15
|
+
)
|
|
16
|
+
from pinecall.domain.names import THE_WIDGET, Channel
|
|
17
|
+
from pinecall.domain.person import THE_FLEET
|
|
18
|
+
from pinecall.domain.scope import Scope
|
|
19
|
+
from pinecall.gateway import _deps
|
|
20
|
+
from pinecall.gateway._call_setup import keys_of, tuned
|
|
21
|
+
from pinecall.gateway._deps import CallsKey, DispatchedDep, GatewayDep, ScopeDep, WorkerKey
|
|
22
|
+
from pinecall.gateway._gateway import Gateway
|
|
23
|
+
from pinecall.gateway._sockets import NO_AGENT, Registration
|
|
24
|
+
from pinecall.providers import catalog
|
|
25
|
+
from pinecall.providers.credentials import Pipeline, pipeline
|
|
26
|
+
from pinecall.tenancy import agents, keys
|
|
27
|
+
from pinecall.tenancy.scopes import Picked
|
|
28
|
+
from pinecall.wire.rest.agents import (
|
|
29
|
+
AgentList,
|
|
30
|
+
AgentRow,
|
|
31
|
+
HoldAudio,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
router = APIRouter()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Every agent answers on the widget; no route row is needed for it.
|
|
38
|
+
ON_THE_WEB: frozenset[Channel] = frozenset({THE_WIDGET})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RouteQuery(BaseModel):
|
|
42
|
+
"""A worker asking for the route a number rings, across every org."""
|
|
43
|
+
|
|
44
|
+
number: str | None = None
|
|
45
|
+
channel: Channel = "phone"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# A worker names the call it asks for before opening it (`for_call`: `call` would ask to act in an
|
|
49
|
+
# opened call's scope), so a canary's share is picked by the call's id; a worker that names none
|
|
50
|
+
# (an older one) is answered the version every call off the canary runs.
|
|
51
|
+
@router.get("/v1/agents/{slug}/config")
|
|
52
|
+
async def agent_config(
|
|
53
|
+
slug: str,
|
|
54
|
+
_key: _deps.DeclarationKey,
|
|
55
|
+
where: ScopeDep,
|
|
56
|
+
gateway: GatewayDep,
|
|
57
|
+
for_call: Annotated[str | None, Query()] = None,
|
|
58
|
+
) -> AgentConfig:
|
|
59
|
+
"""The agent as the scope runs it, for the call named: its declaration under the settings."""
|
|
60
|
+
found = _registration_of(gateway, where, slug)
|
|
61
|
+
configured = await catalog.providers(gateway.connections.pool)
|
|
62
|
+
tuned_config, _ = await tuned(
|
|
63
|
+
gateway.connections.pool, found.config, where, configured, Picked(call=for_call)
|
|
64
|
+
)
|
|
65
|
+
return tuned_config
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# The one answer that carries keys: to the fleet's key, or the org's own worker's. The box's
|
|
69
|
+
# keys leave it only for its own workers: an org's process is handed the org's keys alone.
|
|
70
|
+
@router.get("/v1/agents/{slug}/provider-keys")
|
|
71
|
+
async def agent_credentials(
|
|
72
|
+
slug: str,
|
|
73
|
+
key: WorkerKey,
|
|
74
|
+
where: ScopeDep,
|
|
75
|
+
gateway: GatewayDep,
|
|
76
|
+
for_call: Annotated[str | None, Query()] = None,
|
|
77
|
+
) -> Pipeline:
|
|
78
|
+
"""The three stages a call of the agent runs, each on the key it runs on."""
|
|
79
|
+
found = _registration_of(gateway, where, slug)
|
|
80
|
+
configured = await catalog.providers(gateway.connections.pool)
|
|
81
|
+
tuned_config, _ = await tuned(
|
|
82
|
+
gateway.connections.pool, found.config, where, configured, Picked(call=for_call)
|
|
83
|
+
)
|
|
84
|
+
keys = await keys_of(gateway.connections.pool, gateway.connections.vault, where)
|
|
85
|
+
if THE_FLEET not in key.bearer.key.scopes:
|
|
86
|
+
keys = dataclasses.replace(keys, lends=frozenset())
|
|
87
|
+
now = time.monotonic()
|
|
88
|
+
stages = pipeline(tuned_config, configured, keys).demoting(gateway.counters.failing(now))
|
|
89
|
+
gateway.counters.handed_out((stages.llm.vendor, stages.stt.vendor, stages.tts.vendor), now)
|
|
90
|
+
return stages
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.get("/v1/agents/{slug}/hold-audio")
|
|
94
|
+
async def hold_audio(slug: str, _key: WorkerKey, where: ScopeDep, gateway: GatewayDep) -> HoldAudio:
|
|
95
|
+
"""What a caller of the agent hears while a tool runs."""
|
|
96
|
+
chosen = await agents.hold_of(gateway.connections.pool, where, slug)
|
|
97
|
+
if chosen is None:
|
|
98
|
+
return HoldAudio(played="default")
|
|
99
|
+
return HoldAudio(
|
|
100
|
+
played=chosen.played, sha256=chosen.sha256, seconds=chosen.seconds, name=chosen.name
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@router.get("/v1/agents/{slug}/hold-audio/audio")
|
|
105
|
+
async def hold_clip(slug: str, _key: WorkerKey, where: ScopeDep, gateway: GatewayDep) -> Response:
|
|
106
|
+
"""The org's own clip, Ogg Opus."""
|
|
107
|
+
audio = await agents.hold_audio(gateway.connections.pool, where, slug)
|
|
108
|
+
if audio is None:
|
|
109
|
+
raise NotFound(f"agent {slug} plays no clip of its own")
|
|
110
|
+
return Response(audio, media_type="audio/ogg")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# A route the operator adds answers the next job: nothing is cached.
|
|
114
|
+
@router.get("/v1/routes")
|
|
115
|
+
async def list_routes(
|
|
116
|
+
key: WorkerKey,
|
|
117
|
+
named: DispatchedDep,
|
|
118
|
+
gateway: GatewayDep,
|
|
119
|
+
query: Annotated[RouteQuery, Query()],
|
|
120
|
+
) -> list[Route]:
|
|
121
|
+
"""The routes of a scope; for the fleet's key and a number, the route that number rings."""
|
|
122
|
+
fleet = THE_FLEET in key.bearer.key.scopes
|
|
123
|
+
if fleet and query.number is not None:
|
|
124
|
+
found = await routes.at(gateway.connections.pool, query.channel, query.number)
|
|
125
|
+
return [] if found is None or found.env != key.bearer.key.env else [found]
|
|
126
|
+
where = named if fleet and named is not None else keys.scope_of(key.bearer, key.env)
|
|
127
|
+
return await routes.of_org(gateway.connections.pool, where.org, where.env)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.get("/v1/agents")
|
|
131
|
+
async def list_agents(key: CallsKey, where: ScopeDep, gateway: GatewayDep) -> AgentList:
|
|
132
|
+
"""The org's held agents in the world: one row per slug, one per scope for a team reader."""
|
|
133
|
+
every = _deps.sees_every_scope(key)
|
|
134
|
+
doors: dict[str, set[Channel]] = {}
|
|
135
|
+
for route in await routes.of_org(gateway.connections.pool, where.org, where.env):
|
|
136
|
+
doors.setdefault(route.agent, set()).add(route.channel)
|
|
137
|
+
listed: list[AgentRow] = []
|
|
138
|
+
for registration in gateway.sockets.holding(where, every_corner=every):
|
|
139
|
+
channels = sorted(doors.get(registration.slug, set[Channel]()) | ON_THE_WEB)
|
|
140
|
+
holder = await _deps.named_holder(gateway, registration.scope)
|
|
141
|
+
listed.append(AgentRow(slug=registration.slug, channels=channels, holder=holder))
|
|
142
|
+
return AgentList(agents=listed)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Another org's agent is the same 404 as nobody's: its existence does not leak.
|
|
146
|
+
def _registration_of(gateway: Gateway, where: Scope, slug: str) -> Registration:
|
|
147
|
+
registration = gateway.sockets.of(where, slug)
|
|
148
|
+
if registration is None or registration.scope.org != where.org:
|
|
149
|
+
raise NotFound(NO_AGENT.format(slug=slug))
|
|
150
|
+
return registration
|