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,168 @@
|
|
|
1
|
+
"""Sign-in with an org's identity provider: the redirect out, the callback in, a domain's orgs."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import APIRouter, Query, Request
|
|
7
|
+
from fastapi.responses import RedirectResponse
|
|
8
|
+
|
|
9
|
+
from pinecall.domain.errors import (
|
|
10
|
+
DeclarationRefused,
|
|
11
|
+
NotAllowed,
|
|
12
|
+
NotAvailable,
|
|
13
|
+
NotFound,
|
|
14
|
+
PinecallError,
|
|
15
|
+
)
|
|
16
|
+
from pinecall.domain.person import Member
|
|
17
|
+
from pinecall.gateway._deps import GatewayDep, check_knock, client_of, public_url
|
|
18
|
+
from pinecall.gateway._gateway import Gateway
|
|
19
|
+
from pinecall.tenancy import orgs, signin, sso, sso_domains
|
|
20
|
+
from pinecall.tenancy.signin import A_DOMAIN, Handshake
|
|
21
|
+
from pinecall.wire.rest.accounts import DiscoverSsoRequest, DiscoverSsoResponse, SsoOrgRow
|
|
22
|
+
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# The page a sign-in lands on; the URL carries a one-use login code, never a key.
|
|
27
|
+
THE_CONSOLE = "/"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Where a sign-in begun by `pinecall login` lands, to approve the terminal waiting.
|
|
31
|
+
THE_CARD = "/cli"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
NO_SSO_HERE = "org {org} signs in with no identity provider"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
NO_HANDSHAKE = (
|
|
38
|
+
"no sign-in answers to that: it was finished already, it expired, or it never started"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
ANOTHER_DOMAIN = "{email} is not in a domain {org} signs in with"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# No address is known yet at the redirect, so it is counted by the client's address.
|
|
46
|
+
TOO_MANY_SIGN_INS = "too many sign-ins from here: try again in a minute"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
THE_PROVIDER_SAID = "the sign-in was refused at the provider: {error}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
NO_CODE_BACK = "the provider sent no authorization code back"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
NO_BOX_WIDE = (
|
|
56
|
+
"box-wide sign-in is not in this version: sign in with a password or the org's provider"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
FOUND = 302
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
Pairing = Annotated[str | None, Query(description="the terminal waiting to be signed in")]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# The same 404 for an org unknown and one with no provider, so slugs are not enumerated.
|
|
67
|
+
@router.get("/v1/login/sso")
|
|
68
|
+
async def start_sso(
|
|
69
|
+
org: Annotated[str, Query(description="which org's provider, by id or slug")],
|
|
70
|
+
request: Request,
|
|
71
|
+
gateway: GatewayDep,
|
|
72
|
+
pairing: Pairing = None,
|
|
73
|
+
) -> RedirectResponse:
|
|
74
|
+
"""302 to the org's provider, with a state, a nonce and a PKCE challenge."""
|
|
75
|
+
connections = gateway.connections
|
|
76
|
+
owner = await orgs.find(connections.pool, org)
|
|
77
|
+
wired = (
|
|
78
|
+
None if owner is None else await sso.sso_of(connections.pool, connections.vault, owner.id)
|
|
79
|
+
)
|
|
80
|
+
if owner is None or wired is None:
|
|
81
|
+
raise NotFound(NO_SSO_HERE.format(org=org))
|
|
82
|
+
await check_knock(gateway, f"{client_of(request)} sso/{owner.slug}", TOO_MANY_SIGN_INS)
|
|
83
|
+
provider = await sso.discovered(connections.http, wired.client.issuer)
|
|
84
|
+
redirect_uri = f"{public_url(request, gateway)}{sso.CALLBACK}"
|
|
85
|
+
begun = await sso.handshake(
|
|
86
|
+
gateway.signins.handshakes, redirect_uri, org=owner.id, pairing=pairing
|
|
87
|
+
)
|
|
88
|
+
return RedirectResponse(sso.authorization_url(provider, wired.client, begun), FOUND)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# A refusal mid-redirect goes back to the sign-in page as `?refused=`: a JSON body there is a
|
|
92
|
+
# dead end. A state nobody began is the one refusal answered as JSON.
|
|
93
|
+
@router.get("/v1/login/sso/callback")
|
|
94
|
+
async def finish_sso(
|
|
95
|
+
state: str, gateway: GatewayDep, code: str | None = None, error: str | None = None
|
|
96
|
+
) -> RedirectResponse:
|
|
97
|
+
"""The code exchanged, the person seated, and 302 to the console with a login code."""
|
|
98
|
+
begun = await gateway.signins.handshakes.spend(state)
|
|
99
|
+
if begun is None:
|
|
100
|
+
raise DeclarationRefused(NO_HANDSHAKE)
|
|
101
|
+
if error is not None:
|
|
102
|
+
return _refused(THE_PROVIDER_SAID.format(error=error))
|
|
103
|
+
if not code:
|
|
104
|
+
return _refused(NO_CODE_BACK)
|
|
105
|
+
try:
|
|
106
|
+
member = await _seated(gateway, begun, code)
|
|
107
|
+
except PinecallError as refusal:
|
|
108
|
+
return _refused(str(refusal))
|
|
109
|
+
login, _ = await gateway.signins.codes.mint(member)
|
|
110
|
+
return RedirectResponse(_landing(begun.pairing, login), FOUND)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Says which orgs a domain signs in with, never whether the address is anybody's.
|
|
114
|
+
@router.post("/v1/login/sso/discover")
|
|
115
|
+
async def discover_sso(
|
|
116
|
+
body: DiscoverSsoRequest, request: Request, gateway: GatewayDep
|
|
117
|
+
) -> DiscoverSsoResponse:
|
|
118
|
+
"""The orgs whose provider signs in this address's domain, oldest first."""
|
|
119
|
+
connections = gateway.connections
|
|
120
|
+
email = body.email.strip().lower()
|
|
121
|
+
await check_knock(
|
|
122
|
+
gateway, f"{client_of(request)} sso/{email}", signin.TOO_MANY.format(email=email)
|
|
123
|
+
)
|
|
124
|
+
domain = email.rpartition("@")[2]
|
|
125
|
+
if not A_DOMAIN.match(domain):
|
|
126
|
+
return DiscoverSsoResponse(orgs=[])
|
|
127
|
+
rows: list[SsoOrgRow] = []
|
|
128
|
+
for wired in await sso.sso_with_domain(connections.pool, connections.vault, domain):
|
|
129
|
+
org = await orgs.find(connections.pool, wired.org)
|
|
130
|
+
if org is not None:
|
|
131
|
+
rows.append(SsoOrgRow(org=org.id, slug=org.slug, name=org.name))
|
|
132
|
+
return DiscoverSsoResponse(orgs=rows)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.get("/v1/login/google")
|
|
136
|
+
async def google_sign_in() -> RedirectResponse:
|
|
137
|
+
"""Box-wide "Continue with Google", which this version does not have."""
|
|
138
|
+
raise NotAvailable(NO_BOX_WIDE)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.get("/v1/login/google/callback")
|
|
142
|
+
async def google_callback() -> RedirectResponse:
|
|
143
|
+
"""Box-wide Google's callback, which this version does not have."""
|
|
144
|
+
raise NotAvailable(NO_BOX_WIDE)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _seated(gateway: Gateway, begun: Handshake, code: str) -> Member:
|
|
148
|
+
connections = gateway.connections
|
|
149
|
+
named = begun.org or ""
|
|
150
|
+
wired = await sso.sso_of(connections.pool, connections.vault, named)
|
|
151
|
+
org = await orgs.find(connections.pool, named)
|
|
152
|
+
if wired is None or org is None:
|
|
153
|
+
raise NotFound(NO_SSO_HERE.format(org=named))
|
|
154
|
+
claims = await sso.vouched_for(connections.http, wired.client, begun, code)
|
|
155
|
+
proven = await sso_domains.verified_domains(connections.pool, org.id)
|
|
156
|
+
if not wired.admits(claims.email) or claims.email.rpartition("@")[2].lower() not in proven:
|
|
157
|
+
raise NotAllowed(ANOTHER_DOMAIN.format(email=claims.email, org=org.slug))
|
|
158
|
+
return await sso.seat_vouched(connections.pool, org, wired, claims)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _landing(pairing: str | None, code: str) -> str:
|
|
162
|
+
if pairing is None:
|
|
163
|
+
return f"{THE_CONSOLE}?{httpx.QueryParams({'login': code})}"
|
|
164
|
+
return f"{THE_CARD}?{httpx.QueryParams({'c': pairing, 'login': code})}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _refused(sentence: str) -> RedirectResponse:
|
|
168
|
+
return RedirectResponse(f"{THE_CONSOLE}?{httpx.QueryParams({'refused': sentence})}", FOUND)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""An agent's WhatsApp inbox by contact: the threads, one thread, read, written to."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Query
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from pinecall.channels.whatsapp import WINDOW_S
|
|
10
|
+
from pinecall.domain.errors import (
|
|
11
|
+
Conflict,
|
|
12
|
+
NotFound,
|
|
13
|
+
)
|
|
14
|
+
from pinecall.domain.names import THE_WIDGET, parse_channel
|
|
15
|
+
from pinecall.domain.scope import Scope
|
|
16
|
+
from pinecall.gateway import _deps
|
|
17
|
+
from pinecall.gateway._deps import Acting, CallsKey, GatewayDep, ScopeDep, TalkKey
|
|
18
|
+
from pinecall.gateway._gateway import Gateway
|
|
19
|
+
from pinecall.log import facts, inbox, queries
|
|
20
|
+
from pinecall.tenancy import keys
|
|
21
|
+
from pinecall.wire.commands import SayVerb, SupervisorVerb
|
|
22
|
+
from pinecall.wire.frames import Entry
|
|
23
|
+
from pinecall.wire.parts import Supervisor, ThreadKind
|
|
24
|
+
from pinecall.wire.rest.calls import (
|
|
25
|
+
ThreadLast,
|
|
26
|
+
ThreadList,
|
|
27
|
+
ThreadMessage,
|
|
28
|
+
ThreadMessageRequest,
|
|
29
|
+
ThreadMessageResponse,
|
|
30
|
+
ThreadResponse,
|
|
31
|
+
ThreadRow,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
router = APIRouter()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
THREADS_A_PAGE = 30
|
|
38
|
+
|
|
39
|
+
# The calls merged into one thread, newest first: each costs a log read.
|
|
40
|
+
CALLS_IN_A_THREAD = 20
|
|
41
|
+
|
|
42
|
+
NO_THREAD = "no thread with {contact} on agent {agent} in this corner"
|
|
43
|
+
|
|
44
|
+
ONLY_WHATSAPP = (
|
|
45
|
+
"a message is written on a WhatsApp thread, and {contact}'s newest call is {channel}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
WINDOW_CLOSED = (
|
|
49
|
+
"WhatsApp's window with {contact} closed 24 h after their last message: only a template "
|
|
50
|
+
"reaches them now"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
NOTHING_OPEN = (
|
|
54
|
+
"{contact}'s conversation went quiet and is sealed: their next message opens a new one"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ThreadQuery(BaseModel):
|
|
59
|
+
"""One page of an inbox: after the cursor of the last one, so many contacts."""
|
|
60
|
+
|
|
61
|
+
after: str | None = None
|
|
62
|
+
limit: int = Field(THREADS_A_PAGE, ge=1, le=_deps.LONGEST_LIST)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.get("/v1/agents/{slug}/threads")
|
|
66
|
+
async def list_threads(
|
|
67
|
+
slug: str,
|
|
68
|
+
key: CallsKey,
|
|
69
|
+
where: ScopeDep,
|
|
70
|
+
gateway: GatewayDep,
|
|
71
|
+
page: Annotated[ThreadQuery, Query()],
|
|
72
|
+
) -> ThreadList:
|
|
73
|
+
"""The agent's contacts, the one that moved last first, with what this reader has not read."""
|
|
74
|
+
wanted = inbox.Inbox(where, slug, _reader_of(key))
|
|
75
|
+
found = await inbox.threads(
|
|
76
|
+
gateway.connections.pool, wanted, after=page.after, limit=page.limit
|
|
77
|
+
)
|
|
78
|
+
return ThreadList(threads=[_line_of(row) for row in found.rows], next=found.next)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.get("/v1/agents/{slug}/threads/{contact}")
|
|
82
|
+
async def get_thread(
|
|
83
|
+
slug: str, contact: str, _key: CallsKey, where: ScopeDep, gateway: GatewayDep
|
|
84
|
+
) -> ThreadResponse:
|
|
85
|
+
"""A contact's calls with the agent merged into one thread, oldest first."""
|
|
86
|
+
calls = await _thread_of(gateway, where, slug, contact)
|
|
87
|
+
facts = await queries.facts_of_calls(gateway.connections.pool, calls)
|
|
88
|
+
messages: list[ThreadMessage] = []
|
|
89
|
+
for call in reversed(calls):
|
|
90
|
+
if call in facts:
|
|
91
|
+
messages += _said_on(facts[call], await gateway.logs.store.whole(call))
|
|
92
|
+
name = next((facts[call].name for call in calls if call in facts and facts[call].name), None)
|
|
93
|
+
return ThreadResponse(contact=contact, name=name, messages=messages)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# The read cursor is the person's; a server's key keeps its own.
|
|
97
|
+
@router.post("/v1/agents/{slug}/threads/{contact}/read", status_code=204)
|
|
98
|
+
async def mark_thread_read(
|
|
99
|
+
slug: str, contact: str, key: CallsKey, where: ScopeDep, gateway: GatewayDep
|
|
100
|
+
) -> None:
|
|
101
|
+
"""This reader has read the thread up to now."""
|
|
102
|
+
await _thread_of(gateway, where, slug, contact)
|
|
103
|
+
await inbox.read(
|
|
104
|
+
gateway.connections.pool, inbox.Inbox(where, slug, _reader_of(key)), contact, time.time()
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# A supervisor's `say` on the open conversation, sent to the contact by its reply tap. Meta lets
|
|
109
|
+
# a business write freely only within 24 h of the contact's last message.
|
|
110
|
+
@router.post("/v1/agents/{slug}/threads/{contact}/messages", status_code=202)
|
|
111
|
+
async def send_thread_message(
|
|
112
|
+
slug: str, contact: str, body: ThreadMessageRequest, key: TalkKey, gateway: GatewayDep
|
|
113
|
+
) -> ThreadMessageResponse:
|
|
114
|
+
"""Say something as the agent on the contact's open WhatsApp conversation."""
|
|
115
|
+
where = keys.scope_of(key.bearer, key.env)
|
|
116
|
+
newest = (await _thread_of(gateway, where, slug, contact))[0]
|
|
117
|
+
facts = (await queries.facts_of_calls(gateway.connections.pool, [newest])).get(newest)
|
|
118
|
+
channel = None if facts is None else facts.channel
|
|
119
|
+
if facts is None or channel != "whatsapp":
|
|
120
|
+
raise Conflict(ONLY_WHATSAPP.format(contact=contact, channel=channel))
|
|
121
|
+
# The log's own clock, which stamped when the contact last wrote.
|
|
122
|
+
if gateway.logs.store.clock() - max(facts.heard_at, default=0.0) > WINDOW_S:
|
|
123
|
+
raise Conflict(WINDOW_CLOSED.format(contact=contact))
|
|
124
|
+
served = gateway.live.calls.get(newest)
|
|
125
|
+
if served is None or served.session is None:
|
|
126
|
+
raise Conflict(NOTHING_OPEN.format(contact=contact))
|
|
127
|
+
member = key.bearer.member
|
|
128
|
+
by = Supervisor(id=_reader_of(key), name=None if member is None else member.name)
|
|
129
|
+
await served.session.supervise(SupervisorVerb(by=by, verb=SayVerb(text=body.text)))
|
|
130
|
+
return ThreadMessageResponse(contact=contact, call=newest)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _thread_of(gateway: Gateway, where: Scope, slug: str, contact: str) -> list[str]:
|
|
134
|
+
calls = await inbox.calls_with(
|
|
135
|
+
gateway.connections.pool, where, slug, contact, limit=CALLS_IN_A_THREAD
|
|
136
|
+
)
|
|
137
|
+
if not calls:
|
|
138
|
+
raise NotFound(NO_THREAD.format(contact=contact, agent=slug))
|
|
139
|
+
return calls
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _reader_of(key: Acting) -> str:
|
|
143
|
+
return key.bearer.key.subject or key.bearer.key.key_id
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _line_of(row: inbox.InboxRow) -> ThreadRow:
|
|
147
|
+
newest = row.newest
|
|
148
|
+
kind: ThreadKind = "call" if newest.spoken else ("in" if newest.last_in else "out")
|
|
149
|
+
data = newest.outcome if newest.spoken else newest.last_text
|
|
150
|
+
return ThreadRow(
|
|
151
|
+
contact=row.contact,
|
|
152
|
+
name=row.name,
|
|
153
|
+
channel_last=parse_channel(newest.channel or THE_WIDGET),
|
|
154
|
+
last=ThreadLast(text=data, at=row.moved_at, kind=kind),
|
|
155
|
+
unread=row.unread,
|
|
156
|
+
calls=row.calls,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# A written call is its turns; a spoken call is one pill with its length and whether it came up.
|
|
161
|
+
def _said_on(call_facts: facts.CallFacts, entries: list[Entry]) -> list[ThreadMessage]:
|
|
162
|
+
channel = parse_channel(call_facts.channel or THE_WIDGET)
|
|
163
|
+
if not call_facts.spoken:
|
|
164
|
+
return [
|
|
165
|
+
ThreadMessage(
|
|
166
|
+
kind="in" if entry.type == "turn.user" else "out",
|
|
167
|
+
text=str(entry.data.get("text") or ""),
|
|
168
|
+
at=entry.ts,
|
|
169
|
+
call=call_facts.call,
|
|
170
|
+
channel=channel,
|
|
171
|
+
)
|
|
172
|
+
for entry in entries
|
|
173
|
+
if entry.type in {"turn.user", "turn.agent"}
|
|
174
|
+
]
|
|
175
|
+
ended = next((entry for entry in entries if entry.type == "call.ended"), None)
|
|
176
|
+
lasted = None if ended is None else ended.data.get("duration_s")
|
|
177
|
+
return [
|
|
178
|
+
ThreadMessage(
|
|
179
|
+
kind="call",
|
|
180
|
+
text=call_facts.outcome,
|
|
181
|
+
at=entries[0].ts if entries else 0.0,
|
|
182
|
+
call=call_facts.call,
|
|
183
|
+
channel=channel,
|
|
184
|
+
duration_s=float(lasted) if isinstance(lasted, int | float) else None,
|
|
185
|
+
answered=any(entry.type == "call.started" for entry in entries),
|
|
186
|
+
)
|
|
187
|
+
]
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""The org's meters: the usage feed, a window's insights, an agent's drift, what the org may use."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import UTC, date, datetime, timedelta
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Query
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from pinecall.channels import routes
|
|
11
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
12
|
+
from pinecall.domain.org import Quotas
|
|
13
|
+
from pinecall.gateway._deps import ActingDep, CallsKey, GatewayDep, ScopeDep, UsageKey
|
|
14
|
+
from pinecall.log import drift
|
|
15
|
+
from pinecall.log.drift import Side, Tally
|
|
16
|
+
from pinecall.log.reduce import Usage, UsageRow, totals_by_org
|
|
17
|
+
from pinecall.log.store import DEFAULT_LIMIT
|
|
18
|
+
from pinecall.log.usage import A_DAY_S, AgentWindow, WindowDay, counted_window, metered_page
|
|
19
|
+
from pinecall.tenancy import admission, people, scopes, usage
|
|
20
|
+
from pinecall.tenancy.keys import check_agent
|
|
21
|
+
from pinecall.wire.rest.usage import (
|
|
22
|
+
Drift,
|
|
23
|
+
DriftSide,
|
|
24
|
+
DriftVersion,
|
|
25
|
+
Insights,
|
|
26
|
+
InsightsAgent,
|
|
27
|
+
InsightsBudget,
|
|
28
|
+
InsightsChannels,
|
|
29
|
+
InsightsConversations,
|
|
30
|
+
InsightsDay,
|
|
31
|
+
InsightsEnding,
|
|
32
|
+
InsightsSpend,
|
|
33
|
+
Limit,
|
|
34
|
+
Limits,
|
|
35
|
+
UsagePage,
|
|
36
|
+
UsageTotals,
|
|
37
|
+
)
|
|
38
|
+
from pinecall.wire.rest.usage import UsageRow as UsageRowResponse
|
|
39
|
+
|
|
40
|
+
router = APIRouter()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# An org carries no timezone, so a day is cut in UTC and the answer says so.
|
|
44
|
+
TIMEZONE = "UTC"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# The windows the console offers, in whole UTC days: today, a week, a month.
|
|
48
|
+
WINDOW_DAYS = (1, 7, 30)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
NOT_A_WINDOW = "days={days} is not a window: one of 1, 7 or 30"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
A_VERSION = re.compile(r"v(\d+)")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
NOT_A_SIDE = "{text!r} is neither a day (YYYY-MM-DD) nor a version (v3)"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
BOTH_SIDES = "a drift between versions names both: before=v3&after=v4"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class InsightsQuery(BaseModel):
|
|
64
|
+
"""What insights count: the window's last day (today), its days, one agent or every one."""
|
|
65
|
+
|
|
66
|
+
day: date | None = None
|
|
67
|
+
days: int = 1
|
|
68
|
+
agent: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DriftQuery(BaseModel):
|
|
72
|
+
"""What a drift compares: an agent, two days (today and the day before) or two versions."""
|
|
73
|
+
|
|
74
|
+
agent: str
|
|
75
|
+
before: str | None = None
|
|
76
|
+
after: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# The cursor is the store's position of the last row read: a billing consumer resumes from it
|
|
80
|
+
# and counts nothing twice.
|
|
81
|
+
@router.get("/v1/usage")
|
|
82
|
+
async def usage_feed(
|
|
83
|
+
key: UsageKey,
|
|
84
|
+
gateway: GatewayDep,
|
|
85
|
+
after: Annotated[int, Query(ge=0)] = 0,
|
|
86
|
+
limit: Annotated[int, Query(ge=1, le=DEFAULT_LIMIT)] = DEFAULT_LIMIT,
|
|
87
|
+
) -> UsagePage:
|
|
88
|
+
"""The org's metered rows after the cursor, their totals, and the next cursor."""
|
|
89
|
+
page = await metered_page(gateway.logs.store, after=after, limit=limit, org=key.org)
|
|
90
|
+
totals = totals_by_org(page.rows).get(key.org)
|
|
91
|
+
return UsagePage(
|
|
92
|
+
rows=[usage_row_response(row) for row in page.rows],
|
|
93
|
+
totals=None if totals is None else usage_totals(totals),
|
|
94
|
+
next=page.next,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# The window is the scope's, or one agent's of it; the budget is the org's across both worlds and
|
|
99
|
+
# its month is the window's last day's, since it is one bill.
|
|
100
|
+
@router.get("/v1/insights")
|
|
101
|
+
async def insights(
|
|
102
|
+
key: CallsKey,
|
|
103
|
+
scope: ScopeDep,
|
|
104
|
+
gateway: GatewayDep,
|
|
105
|
+
query: Annotated[InsightsQuery, Query()],
|
|
106
|
+
) -> Insights:
|
|
107
|
+
"""Whole UTC days of the key's world and scope at a glance, and the month's spend."""
|
|
108
|
+
if query.days not in WINDOW_DAYS:
|
|
109
|
+
raise DeclarationRefused(NOT_A_WINDOW.format(days=query.days))
|
|
110
|
+
if query.agent is not None:
|
|
111
|
+
check_agent(key.bearer, query.agent)
|
|
112
|
+
pool = gateway.connections.pool
|
|
113
|
+
last = query.day or datetime.now(UTC).date()
|
|
114
|
+
first = last - timedelta(days=query.days - 1)
|
|
115
|
+
start, end = _opening(first), _opening(last) + A_DAY_S
|
|
116
|
+
counted = await counted_window(pool, scope, start, end, query.agent)
|
|
117
|
+
spent = await usage.spent_in(pool, key.org, last)
|
|
118
|
+
quotas = await admission.quotas_of(pool, key.org, key.env)
|
|
119
|
+
return Insights(
|
|
120
|
+
day=last.isoformat(),
|
|
121
|
+
days=query.days,
|
|
122
|
+
timezone=TIMEZONE,
|
|
123
|
+
conversations=InsightsConversations(now=counted.calls, before=counted.before),
|
|
124
|
+
resolved_rate=counted.unescalated / counted.finished if counted.finished else None,
|
|
125
|
+
median_e2e_s=counted.median_e2e,
|
|
126
|
+
spend_usd=counted.spent,
|
|
127
|
+
channels=_channels(counted.channels),
|
|
128
|
+
judged=counted.judged,
|
|
129
|
+
passed=counted.passed,
|
|
130
|
+
escalated=counted.escalated,
|
|
131
|
+
mean_length_s=counted.mean_length,
|
|
132
|
+
endings=[InsightsEnding(reason=reason, count=count) for reason, count in counted.endings],
|
|
133
|
+
series=[_day(day) for day in counted.days],
|
|
134
|
+
sessions_total=counted.total,
|
|
135
|
+
live=counted.live,
|
|
136
|
+
agents=[
|
|
137
|
+
InsightsAgent(
|
|
138
|
+
slug=agent.slug, calls=agent.calls, score=agent.score, spend=_spend(agent)
|
|
139
|
+
)
|
|
140
|
+
for agent in counted.agents
|
|
141
|
+
],
|
|
142
|
+
stages=await drift.stages_of_window(pool, scope, first, last, query.agent),
|
|
143
|
+
budget=InsightsBudget(limit_usd=quotas.budget_usd, spent_usd_month=spent),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# Folded when each call was sealed (log/drift.py): two days or two versions read a few rows each.
|
|
148
|
+
@router.get("/v1/insights/drift")
|
|
149
|
+
async def insights_drift(
|
|
150
|
+
key: CallsKey, scope: ScopeDep, gateway: GatewayDep, query: Annotated[DriftQuery, Query()]
|
|
151
|
+
) -> Drift:
|
|
152
|
+
"""What moved between two days or versions of an agent, and the versions run or set."""
|
|
153
|
+
check_agent(key.bearer, query.agent)
|
|
154
|
+
pool = gateway.connections.pool
|
|
155
|
+
before, after = _sides(query)
|
|
156
|
+
tallies = (
|
|
157
|
+
await drift.tally(pool, scope, query.agent, before),
|
|
158
|
+
await drift.tally(pool, scope, query.agent, after),
|
|
159
|
+
)
|
|
160
|
+
judges, stages = drift.compared(*tallies)
|
|
161
|
+
ran = tallies[0].versions | tallies[1].versions
|
|
162
|
+
days = [side.day for side in (before, after) if side.day is not None]
|
|
163
|
+
window = (_opening(min(days)), _opening(max(days)) + A_DAY_S) if days else (0.0, 0.0)
|
|
164
|
+
noted = await scopes.versions_noted(pool, scope, query.agent, ran, window)
|
|
165
|
+
return Drift(
|
|
166
|
+
agent=query.agent,
|
|
167
|
+
world=scope.env,
|
|
168
|
+
before=_side(before, tallies[0]),
|
|
169
|
+
after=_side(after, tallies[1]),
|
|
170
|
+
judges=judges,
|
|
171
|
+
stages=stages,
|
|
172
|
+
versions=[
|
|
173
|
+
DriftVersion(
|
|
174
|
+
version=row.version,
|
|
175
|
+
holder=row.holder,
|
|
176
|
+
author=row.author,
|
|
177
|
+
note=row.note,
|
|
178
|
+
set_at=row.set_at.timestamp(),
|
|
179
|
+
ran_before=row.version in tallies[0].versions,
|
|
180
|
+
ran_after=row.version in tallies[1].versions,
|
|
181
|
+
)
|
|
182
|
+
for row in noted
|
|
183
|
+
],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Any key of the org. `used` reads what admission reads, so this page and a refusal agree.
|
|
188
|
+
@router.get("/v1/limits")
|
|
189
|
+
async def limits(key: ActingDep, gateway: GatewayDep) -> Limits:
|
|
190
|
+
"""Each quota of the key's world as {limit, used}, the lends, and where to buy more."""
|
|
191
|
+
pool, org, env = gateway.connections.pool, key.org, key.env
|
|
192
|
+
quotas = await admission.quotas_of(pool, org, env)
|
|
193
|
+
used = await usage.used(pool, org, env, usage.month_of(gateway.logs.store.clock()))
|
|
194
|
+
return Limits(
|
|
195
|
+
minutes=Limit(limit=quotas.minutes, used=used.minutes),
|
|
196
|
+
messages=Limit(limit=quotas.messages, used=used.messages),
|
|
197
|
+
llm_tokens=Limit(limit=quotas.llm_tokens, used=used.input_tokens + used.output_tokens),
|
|
198
|
+
concurrent_calls=Limit(limit=quotas.concurrent_calls, used=gateway.live.running(org, env)),
|
|
199
|
+
agents=Limit(limit=quotas.agents, used=len(gateway.sockets.slugs(org))),
|
|
200
|
+
seats=Limit(limit=quotas.seats, used=await people.seated(pool, org)),
|
|
201
|
+
numbers=Limit(limit=quotas.numbers, used=await routes.managed_in(pool, org, env)),
|
|
202
|
+
lends=_lends(quotas),
|
|
203
|
+
billing_url=gateway.connections.settings.billing_url,
|
|
204
|
+
world=env,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def usage_row_response(row: UsageRow) -> UsageRowResponse:
|
|
209
|
+
"""A folded row as the feed sends it."""
|
|
210
|
+
return UsageRowResponse(
|
|
211
|
+
cursor=row.cursor,
|
|
212
|
+
org=row.org,
|
|
213
|
+
agent=row.agent,
|
|
214
|
+
call=row.call,
|
|
215
|
+
type=row.type,
|
|
216
|
+
at=row.at,
|
|
217
|
+
minutes=row.used.minutes,
|
|
218
|
+
messages=row.used.messages,
|
|
219
|
+
input_tokens=row.used.input_tokens,
|
|
220
|
+
output_tokens=row.used.output_tokens,
|
|
221
|
+
characters=row.used.characters,
|
|
222
|
+
judge_calls=row.used.judge_calls,
|
|
223
|
+
cost_usd=row.used.cost_usd,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def usage_totals(used: Usage) -> UsageTotals:
|
|
228
|
+
"""A sum of usage as the feed sends it."""
|
|
229
|
+
return UsageTotals(
|
|
230
|
+
calls=used.calls,
|
|
231
|
+
minutes=used.minutes,
|
|
232
|
+
messages=used.messages,
|
|
233
|
+
input_tokens=used.input_tokens,
|
|
234
|
+
output_tokens=used.output_tokens,
|
|
235
|
+
characters=used.characters,
|
|
236
|
+
judge_calls=used.judge_calls,
|
|
237
|
+
cost_usd=used.cost_usd,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# Two days by default: today and the day before it, in UTC.
|
|
242
|
+
def _sides(query: DriftQuery) -> tuple[Side, Side]:
|
|
243
|
+
after = _side_of(query.after) if query.after is not None else Side(day=datetime.now(UTC).date())
|
|
244
|
+
if query.before is not None:
|
|
245
|
+
return _side_of(query.before), after
|
|
246
|
+
if after.day is None:
|
|
247
|
+
raise DeclarationRefused(BOTH_SIDES)
|
|
248
|
+
return Side(day=after.day - timedelta(days=1)), after
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _side_of(text: str) -> Side:
|
|
252
|
+
version = A_VERSION.fullmatch(text)
|
|
253
|
+
if version is not None:
|
|
254
|
+
return Side(version=int(version.group(1)))
|
|
255
|
+
try:
|
|
256
|
+
return Side(day=date.fromisoformat(text))
|
|
257
|
+
except ValueError as refused:
|
|
258
|
+
raise DeclarationRefused(NOT_A_SIDE.format(text=text)) from refused
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _side(side: Side, tallied: Tally) -> DriftSide:
|
|
262
|
+
return DriftSide(
|
|
263
|
+
day=None if side.day is None else side.day.isoformat(),
|
|
264
|
+
version=side.version,
|
|
265
|
+
versions=sorted(tallied.versions),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _channels(counted: dict[str, int]) -> InsightsChannels:
|
|
270
|
+
return InsightsChannels(
|
|
271
|
+
phone=counted["phone"], web=counted["web"], whatsapp=counted["whatsapp"]
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _day(day: WindowDay) -> InsightsDay:
|
|
276
|
+
return InsightsDay(
|
|
277
|
+
day=day.day.isoformat(),
|
|
278
|
+
**day.channels,
|
|
279
|
+
spend_usd=day.spent,
|
|
280
|
+
judged=day.judged,
|
|
281
|
+
passed=day.passed,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _spend(agent: AgentWindow) -> InsightsSpend:
|
|
286
|
+
total = sum(agent.spend.values())
|
|
287
|
+
return InsightsSpend(
|
|
288
|
+
llm_usd=agent.spend["llm"],
|
|
289
|
+
stt_usd=agent.spend["stt"],
|
|
290
|
+
tts_usd=agent.spend["tts"],
|
|
291
|
+
phone_usd=agent.spend["phone"],
|
|
292
|
+
platform_usd=agent.spend["platform"],
|
|
293
|
+
minutes=round(agent.minutes, 3),
|
|
294
|
+
per_minute_usd=round(total / agent.minutes, 6) if agent.minutes > 0 else None,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _lends(quotas: Quotas) -> list[str] | None:
|
|
299
|
+
return None if quotas.lends is None else sorted(quotas.lends)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _opening(day: date) -> float:
|
|
303
|
+
return datetime(day.year, day.month, day.day, tzinfo=UTC).timestamp()
|