callva-livekit 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ """Per-call configuration resolution.
2
+
3
+ Configuration reaches the agent through agent dispatch metadata — ``ctx.job.metadata`` —
4
+ either as a body or as a pointer to an endpoint, or from an endpoint named in the
5
+ environment. Room metadata is deliberately not used: it is broadcast to every participant
6
+ in the room, so a prompt placed there is readable by any connected client.
7
+ """
8
+
9
+ from .models import CallConfig, Variables
10
+ from .resolver import ConfigError, as_path, load, request_payload
11
+ from .template import render
12
+
13
+ __all__ = [
14
+ "CallConfig",
15
+ "ConfigError",
16
+ "Variables",
17
+ "as_path",
18
+ "load",
19
+ "render",
20
+ "request_payload",
21
+ ]
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from ..core.transport import WebhookTarget
7
+ from .template import render
8
+
9
+
10
+ class Variables(dict):
11
+ """The call's variables, with their JSON types intact.
12
+
13
+ A number stays a number and a boolean stays a boolean; only substitution into a
14
+ prompt takes the string form. The typed accessors are for reading a variable in code
15
+ without re-checking what the producer sent.
16
+ """
17
+
18
+ def get_str(self, name: str, default: str | None = None) -> str | None:
19
+ value = self.get(name)
20
+ return default if value is None else str(value)
21
+
22
+ def get_int(self, name: str, default: int | None = None) -> int | None:
23
+ value = self.get(name)
24
+ if isinstance(value, bool) or value is None:
25
+ return default
26
+ try:
27
+ return int(value)
28
+ except (TypeError, ValueError):
29
+ return default
30
+
31
+ def get_float(self, name: str, default: float | None = None) -> float | None:
32
+ value = self.get(name)
33
+ if isinstance(value, bool) or value is None:
34
+ return default
35
+ try:
36
+ return float(value)
37
+ except (TypeError, ValueError):
38
+ return default
39
+
40
+ def get_bool(self, name: str, default: bool | None = None) -> bool | None:
41
+ value = self.get(name)
42
+ if value is None:
43
+ return default
44
+ if isinstance(value, bool):
45
+ return value
46
+ if isinstance(value, (int, float)):
47
+ return bool(value)
48
+ if isinstance(value, str):
49
+ lowered = value.strip().lower()
50
+ if lowered in ("1", "true", "yes", "on"):
51
+ return True
52
+ if lowered in ("0", "false", "no", "off"):
53
+ return False
54
+ return default
55
+
56
+
57
+ @dataclass
58
+ class CallConfig:
59
+ """The configuration resolved for one call.
60
+
61
+ ``prompt`` and ``greeting`` come back rendered. The unrendered forms are kept beside
62
+ them for callers that need the original.
63
+ """
64
+
65
+ prompt: str | None = None
66
+ greeting: str | None = None
67
+ raw_prompt: str | None = None
68
+ raw_greeting: str | None = None
69
+ variables: Variables = field(default_factory=Variables)
70
+ webhook: WebhookTarget | None = None
71
+ extra: dict[str, Any] = field(default_factory=dict)
72
+ source: str = "none"
73
+ """Where this came from: ``metadata``, ``file``, ``url``, or ``none``."""
74
+
75
+ @property
76
+ def empty(self) -> bool:
77
+ return not any((self.prompt, self.greeting, self.variables, self.extra))
78
+
79
+ @classmethod
80
+ def parse(cls, payload: Any, *, source: str) -> CallConfig:
81
+ """Build a config from a decoded response body. Never raises."""
82
+ if not isinstance(payload, dict):
83
+ return cls(source=source)
84
+
85
+ variables = payload.get("variables")
86
+ variables = Variables(variables) if isinstance(variables, dict) else Variables()
87
+
88
+ raw_prompt = payload.get("prompt")
89
+ raw_prompt = raw_prompt if isinstance(raw_prompt, str) else None
90
+ raw_greeting = payload.get("greeting")
91
+ raw_greeting = raw_greeting if isinstance(raw_greeting, str) else None
92
+
93
+ extra = payload.get("extra")
94
+
95
+ return cls(
96
+ prompt=render(raw_prompt, variables),
97
+ greeting=render(raw_greeting, variables),
98
+ raw_prompt=raw_prompt,
99
+ raw_greeting=raw_greeting,
100
+ variables=variables,
101
+ webhook=WebhookTarget.from_dict(payload.get("webhook")),
102
+ extra=extra if isinstance(extra, dict) else {},
103
+ source=source,
104
+ )
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+ from urllib.parse import urlparse
8
+ from urllib.request import url2pathname
9
+
10
+ from ..core import env, transport
11
+ from ..core import state as _state
12
+ from ..core.log import logger
13
+ from .models import CallConfig
14
+
15
+ OnError = Literal["terminate", "continue"]
16
+
17
+
18
+ class ConfigError(RuntimeError):
19
+ """Configuration for this call could not be resolved."""
20
+
21
+
22
+ def as_path(endpoint: str) -> Path | None:
23
+ """A local file, or ``None`` if this names a remote endpoint.
24
+
25
+ ``file:///etc/agent.json`` and a bare ``./agent.json`` both mean the same thing. A
26
+ file answers instantly and needs no participant, which makes it the shortest possible
27
+ development loop; it cannot answer per caller, which is why it is not the production
28
+ channel.
29
+ """
30
+ if endpoint.startswith("file://"):
31
+ return Path(url2pathname(urlparse(endpoint).path))
32
+ if "://" in endpoint:
33
+ return None
34
+ return Path(endpoint).expanduser()
35
+
36
+
37
+ async def _read_file(path: Path) -> Any:
38
+ text = await asyncio.get_running_loop().run_in_executor(None, path.read_text)
39
+ return json.loads(text)
40
+
41
+
42
+ async def load(
43
+ *,
44
+ url: str | None = None,
45
+ api_key: str | None = None,
46
+ direction: str | None = None,
47
+ on_error: OnError = "terminate",
48
+ timeout: float | None = None,
49
+ ) -> CallConfig:
50
+ """Resolve the configuration for this call.
51
+
52
+ Resolution order:
53
+
54
+ 1. a body inside ``ctx.job.metadata`` — returns immediately, before anyone has joined
55
+ 2. a pointer inside ``ctx.job.metadata`` — followed
56
+ 3. ``CONFIG_URL`` (or the ``url`` argument) — followed
57
+
58
+ A pointer is either an endpoint, asked with the call's own context as the request body
59
+ so the responder can answer "who called which number", or a local file — ``file://…``
60
+ or a plain path — read as it is.
61
+
62
+ Only the endpoint path needs the SIP envelope to build its request, so only it waits
63
+ for the participant to join. A body in metadata and a file both answer immediately.
64
+
65
+ When configuration cannot be resolved the call is terminated and the reason logged: an
66
+ agent without its prompt is a broken call either way, and failing quietly hides it.
67
+ Pass ``on_error="continue"`` to receive an empty config instead.
68
+ """
69
+ st = _state.state()
70
+
71
+ if st.config is not None:
72
+ return st.config
73
+
74
+ envelope = st.envelope
75
+
76
+ if envelope.config is not None:
77
+ return _store(st, CallConfig.parse(envelope.config, source="metadata"))
78
+
79
+ endpoint = envelope.config_url or url or env.get("CONFIG_URL")
80
+ if not endpoint:
81
+ logger.debug("no configuration source: job metadata carries none and no URL is set")
82
+ return _store(st, CallConfig(source="none"))
83
+
84
+ path = as_path(endpoint)
85
+ if path is not None:
86
+ try:
87
+ body = await _read_file(path)
88
+ except (OSError, ValueError) as exc:
89
+ return _fail(st, on_error, f"could not read configuration from {path}: {exc}")
90
+
91
+ config = CallConfig.parse(body, source="file")
92
+ if config.empty:
93
+ return _fail(st, on_error, f"configuration file {path} carried nothing usable")
94
+
95
+ logger.debug("resolved configuration from %s", path)
96
+ return _store(st, config)
97
+
98
+ try:
99
+ participant = await st.ctx.wait_for_participant()
100
+ except Exception as exc:
101
+ return _fail(st, on_error, f"no participant joined, cannot request configuration: {exc}")
102
+
103
+ identity = _state.ensure_identity(st, participant=participant, direction=direction)
104
+
105
+ try:
106
+ body = await transport.fetch_json(
107
+ endpoint,
108
+ payload=request_payload(st, participant),
109
+ api_key=api_key or env.get("CONFIG_API_KEY"),
110
+ timeout=timeout,
111
+ )
112
+ except transport.FetchError as exc:
113
+ return _fail(st, on_error, f"configuration request to {endpoint} failed: {exc}")
114
+
115
+ config = CallConfig.parse(body, source="url")
116
+ if config.empty:
117
+ return _fail(st, on_error, f"configuration response from {endpoint} carried nothing usable")
118
+
119
+ logger.debug("resolved configuration for call %s from %s", identity.id, endpoint)
120
+ return _store(st, config)
121
+
122
+
123
+ def request_payload(st: _state.CallState, participant: Any) -> dict[str, Any]:
124
+ """The body of a configuration request.
125
+
126
+ The request is the question: it carries who is calling, which number they reached and
127
+ how the call arrived, so the responder can decide what to send back.
128
+ """
129
+ identity = _state.ensure_identity(st, participant=participant)
130
+ job = st.ctx.job
131
+
132
+ return {
133
+ "room": getattr(job.room, "name", None),
134
+ "job_id": job.id,
135
+ "dispatch_id": getattr(job, "dispatch_id", None) or None,
136
+ "agent_name": getattr(job, "agent_name", None) or None,
137
+ "direction": identity.direction,
138
+ "from": identity.from_party.to_dict(),
139
+ "to": identity.to_party.to_dict(),
140
+ "sip": identity.sip,
141
+ "participant_identity": getattr(participant, "identity", None),
142
+ "metadata": st.envelope.raw,
143
+ }
144
+
145
+
146
+ def _store(st: _state.CallState, config: CallConfig) -> CallConfig:
147
+ st.config = config
148
+ if config.webhook is not None:
149
+ st.webhook = config.webhook
150
+ return config
151
+
152
+
153
+ def _fail(st: _state.CallState, on_error: OnError, reason: str) -> CallConfig:
154
+ if on_error == "continue":
155
+ logger.error("%s; continuing without configuration", reason)
156
+ return _store(st, CallConfig(source="none"))
157
+
158
+ logger.error("%s; terminating the call", reason)
159
+ try:
160
+ st.ctx.shutdown(reason="callva: configuration unavailable")
161
+ except Exception:
162
+ logger.debug("could not request shutdown", exc_info=True)
163
+ raise ConfigError(reason)
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+ from ..core.log import logger
8
+
9
+ PLACEHOLDER = re.compile(
10
+ r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*\}\}"
11
+ )
12
+
13
+ _MISSING = object()
14
+
15
+
16
+ def _lookup(variables: Mapping[str, Any], path: str) -> Any:
17
+ cursor: Any = variables
18
+ for part in path.split("."):
19
+ if not isinstance(cursor, Mapping) or part not in cursor:
20
+ return _MISSING
21
+ cursor = cursor[part]
22
+ return cursor
23
+
24
+
25
+ def _stringify(value: Any) -> str:
26
+ """Render a value the way its producer wrote it.
27
+
28
+ Booleans come back as ``true``/``false`` rather than Python's capitalised form, and a
29
+ null renders as nothing at all, because both end up inside a prompt a model reads.
30
+ """
31
+ if value is None:
32
+ return ""
33
+ if isinstance(value, bool):
34
+ return "true" if value else "false"
35
+ return str(value)
36
+
37
+
38
+ def render(text: str | None, variables: Mapping[str, Any] | None) -> str | None:
39
+ """Substitute ``{{ name }}`` placeholders from ``variables``.
40
+
41
+ A placeholder with no matching variable is left exactly as it was and logged at
42
+ warning level. Rendering never raises and never evaluates anything: one missing key
43
+ must not take down a call that is already ringing.
44
+ """
45
+ if not text or not variables:
46
+ return text
47
+
48
+ missing: list[str] = []
49
+
50
+ def substitute(match: re.Match[str]) -> str:
51
+ path = match.group(1)
52
+ value = _lookup(variables, path)
53
+ if value is _MISSING:
54
+ missing.append(path)
55
+ return match.group(0)
56
+ return _stringify(value)
57
+
58
+ rendered = PLACEHOLDER.sub(substitute, text)
59
+
60
+ if missing:
61
+ logger.warning(
62
+ "left %s unresolved placeholder(s) in place: %s",
63
+ len(missing),
64
+ ", ".join(sorted(set(missing))),
65
+ )
66
+
67
+ return rendered
@@ -0,0 +1,40 @@
1
+ """Shared call identity, per-job state, transport and logging.
2
+
3
+ Modules in this package never import one another. They read and write the
4
+ :class:`CallState` that lives here, keyed off the ambient LiveKit job, which is how
5
+ configuration resolved by one module reaches another.
6
+ """
7
+
8
+ from .envelope import ENVELOPE_KEY, DispatchEnvelope, parse
9
+ from .identity import INBOUND, OUTBOUND, CallIdentity, Party, resolve, resolve_direction
10
+ from .log import logger
11
+ from .state import CallState, NoJobContext, context, ensure_identity
12
+ from .state import state as call_state
13
+ from .transport import WebhookTarget, post_file, post_json
14
+ from .version import __version__
15
+
16
+ # `state` deliberately stays bound to the submodule: re-exporting the function under that
17
+ # name would shadow it, and every internal `from ..core import state` would silently get a
18
+ # function instead of the module.
19
+
20
+ __all__ = [
21
+ "ENVELOPE_KEY",
22
+ "INBOUND",
23
+ "OUTBOUND",
24
+ "CallIdentity",
25
+ "CallState",
26
+ "DispatchEnvelope",
27
+ "NoJobContext",
28
+ "Party",
29
+ "WebhookTarget",
30
+ "__version__",
31
+ "call_state",
32
+ "context",
33
+ "ensure_identity",
34
+ "logger",
35
+ "parse",
36
+ "post_file",
37
+ "post_json",
38
+ "resolve",
39
+ "resolve_direction",
40
+ ]
@@ -0,0 +1,35 @@
1
+ """Environment variables.
2
+
3
+ Names describe the job, not the vendor. This is an extension to the LiveKit Agents SDK;
4
+ that a URL happens to point at CallVA is configuration, not identity.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+
12
+ def get(name: str, default: str | None = None) -> str | None:
13
+ """Read an environment variable, treating a blank value as unset."""
14
+ value = os.environ.get(name)
15
+ if value is None:
16
+ return default
17
+ value = value.strip()
18
+ return value or default
19
+
20
+
21
+ def get_bool(name: str, default: bool = False) -> bool:
22
+ value = get(name)
23
+ if value is None:
24
+ return default
25
+ return value.lower() in ("1", "true", "yes", "on")
26
+
27
+
28
+ def get_float(name: str, default: float | None = None) -> float | None:
29
+ value = get(name)
30
+ if value is None:
31
+ return default
32
+ try:
33
+ return float(value)
34
+ except ValueError:
35
+ return default
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from .log import logger
8
+
9
+ ENVELOPE_KEY = "callva"
10
+
11
+ _OWN_KEYS = frozenset({"call_id", "direction", "config", "config_url", "webhook"})
12
+
13
+
14
+ @dataclass
15
+ class DispatchEnvelope:
16
+ """What the dispatcher said about this call, read from ``ctx.job.metadata``.
17
+
18
+ Job metadata is a free-form string that the host application may already be using for
19
+ its own purposes, so the envelope is looked for under a ``callva`` key first. A
20
+ top-level object is only claimed when it carries keys that are unambiguously ours.
21
+ """
22
+
23
+ call_id: str | None = None
24
+ direction: str | None = None
25
+ config: dict[str, Any] | None = None
26
+ config_url: str | None = None
27
+ webhook: dict[str, Any] | None = None
28
+ raw: str | None = None
29
+ """The original metadata string, forwarded to a config endpoint unchanged."""
30
+
31
+ extra: dict[str, Any] = field(default_factory=dict)
32
+ """Everything else found alongside our keys."""
33
+
34
+ @property
35
+ def empty(self) -> bool:
36
+ return not any((self.call_id, self.direction, self.config, self.config_url, self.webhook))
37
+
38
+
39
+ def parse(metadata: str | None) -> DispatchEnvelope:
40
+ """Parse job metadata into an envelope. Never raises."""
41
+ if not metadata or not metadata.strip():
42
+ return DispatchEnvelope()
43
+
44
+ try:
45
+ decoded = json.loads(metadata)
46
+ except (ValueError, TypeError):
47
+ logger.debug("job metadata is not JSON, no dispatch envelope taken from it")
48
+ return DispatchEnvelope(raw=metadata)
49
+
50
+ if not isinstance(decoded, dict):
51
+ return DispatchEnvelope(raw=metadata)
52
+
53
+ scoped = decoded.get(ENVELOPE_KEY)
54
+ if isinstance(scoped, dict):
55
+ body = scoped
56
+ elif _OWN_KEYS & decoded.keys():
57
+ body = decoded
58
+ else:
59
+ return DispatchEnvelope(raw=metadata)
60
+
61
+ def _dict(key: str) -> dict[str, Any] | None:
62
+ value = body.get(key)
63
+ return value if isinstance(value, dict) else None
64
+
65
+ def _str(key: str) -> str | None:
66
+ value = body.get(key)
67
+ return value.strip() or None if isinstance(value, str) else None
68
+
69
+ return DispatchEnvelope(
70
+ call_id=_str("call_id"),
71
+ direction=_str("direction"),
72
+ config=_dict("config"),
73
+ config_url=_str("config_url"),
74
+ webhook=_dict("webhook"),
75
+ raw=metadata,
76
+ extra={k: v for k, v in body.items() if k not in _OWN_KEYS},
77
+ )
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from . import env
10
+ from .envelope import DispatchEnvelope
11
+ from .log import logger
12
+
13
+ INBOUND = "inbound"
14
+ OUTBOUND = "outbound"
15
+
16
+ SIP_PREFIX = "sip."
17
+
18
+ _REMOTE_NUMBER_KEYS = ("phoneNumber", "phone_number")
19
+ _LOCAL_NUMBER_KEYS = ("trunkPhoneNumber", "trunk_phone_number")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Party:
24
+ """One end of the call."""
25
+
26
+ number: str | None = None
27
+ identity: str | None = None
28
+ name: str | None = None
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ return {"number": self.number, "identity": self.identity, "name": self.name}
32
+
33
+
34
+ @dataclass
35
+ class CallIdentity:
36
+ """Who is on this call, which way it goes, and what to call it.
37
+
38
+ Derived by the package. Nothing here is taken from the call's configuration: an
39
+ inbound call is described by the SIP envelope it arrived in, and a dispatched call by
40
+ what the dispatcher declared.
41
+ """
42
+
43
+ id: str
44
+ direction: str
45
+ from_party: Party
46
+ to_party: Party
47
+ sip: dict[str, Any] | None = None
48
+ started_at: float = 0.0
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "id": self.id,
53
+ "direction": self.direction,
54
+ "from": self.from_party.to_dict(),
55
+ "to": self.to_party.to_dict(),
56
+ }
57
+
58
+
59
+ def sip_attributes(attributes: Mapping[str, Any] | None) -> dict[str, Any] | None:
60
+ """Collect the ``sip.*`` participant attributes into a nested dict, unchanged.
61
+
62
+ ``sip.twilio.callSid`` becomes ``{"twilio": {"callSid": ...}}``. Keys are never
63
+ renamed and nothing is dropped, so attributes LiveKit adds later flow through without
64
+ a release here.
65
+ """
66
+ if not attributes:
67
+ return None
68
+
69
+ tree: dict[str, Any] = {}
70
+ for key, value in attributes.items():
71
+ if not key.startswith(SIP_PREFIX):
72
+ continue
73
+ parts = key[len(SIP_PREFIX) :].split(".")
74
+ cursor = tree
75
+ for part in parts[:-1]:
76
+ existing = cursor.get(part)
77
+ if not isinstance(existing, dict):
78
+ existing = {}
79
+ cursor[part] = existing
80
+ cursor = existing
81
+ cursor[parts[-1]] = value
82
+
83
+ return tree or None
84
+
85
+
86
+ def _first(source: Mapping[str, Any], keys: tuple[str, ...]) -> str | None:
87
+ for key in keys:
88
+ value = source.get(key)
89
+ if isinstance(value, str) and value.strip():
90
+ return value.strip()
91
+ return None
92
+
93
+
94
+ def resolve_direction(envelope: DispatchEnvelope, override: str | None = None) -> str:
95
+ """Resolve the call direction. Never inferred from participant state.
96
+
97
+ The dispatcher's declaration wins, then an explicit override or ``CALL_DIRECTION``,
98
+ then inbound. The default is sound rather than a guess: an outbound call is always
99
+ placed by someone, so it always arrives with dispatch metadata.
100
+ """
101
+ for candidate in (envelope.direction, override, env.get("CALL_DIRECTION")):
102
+ if not candidate:
103
+ continue
104
+ value = candidate.strip().lower()
105
+ if value in (INBOUND, OUTBOUND):
106
+ return value
107
+ logger.warning(
108
+ "ignoring unknown call direction %r, expected inbound or outbound", candidate
109
+ )
110
+
111
+ return INBOUND
112
+
113
+
114
+ def resolve(
115
+ *,
116
+ envelope: DispatchEnvelope,
117
+ participant: Any | None = None,
118
+ direction: str | None = None,
119
+ started_at: float | None = None,
120
+ ) -> CallIdentity:
121
+ """Build the call identity from the dispatch envelope and the SIP envelope."""
122
+ resolved_direction = resolve_direction(envelope, direction)
123
+
124
+ attributes = getattr(participant, "attributes", None) or {}
125
+ sip = sip_attributes(attributes)
126
+
127
+ remote = Party(
128
+ number=_first(sip, _REMOTE_NUMBER_KEYS) if sip else None,
129
+ identity=getattr(participant, "identity", None) or None,
130
+ name=getattr(participant, "name", None) or None,
131
+ )
132
+ local = Party(number=_first(sip, _LOCAL_NUMBER_KEYS) if sip else None)
133
+
134
+ if resolved_direction == OUTBOUND:
135
+ from_party, to_party = local, remote
136
+ else:
137
+ from_party, to_party = remote, local
138
+
139
+ return CallIdentity(
140
+ id=envelope.call_id or uuid.uuid4().hex,
141
+ direction=resolved_direction,
142
+ from_party=from_party,
143
+ to_party=to_party,
144
+ sip=sip,
145
+ started_at=started_at if started_at is not None else time.time(),
146
+ )
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ logger = logging.getLogger("callva.livekit")
6
+ """The package logger.
7
+
8
+ The package never sets a level, never attaches a handler and never touches the root
9
+ logger. Whatever the host application has configured is what applies.
10
+ """