Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
## Unreleased

### Changed — Twilio telephony billing is now direction- and country-aware

`calculateTelephonyCost` / `calculate_telephony_cost` accept new optional
`direction`, `destCountry` / `dest_country`, `destType` / `dest_type`
arguments. When threaded through by the carrier adapter, the
`cost.telephony` figure on the call log and dashboard resolves from a
per-country rate table (`TWILIO_PRICING_MATRIX`) instead of the flat
$0.0085/min US-inbound-local rate. The legacy rate was correct for the
99% case of an agent receiving calls on a US local number, but
under-estimated outbound by 9-40x for international mobile destinations
(US → IT mobile is $0.3473/min vs $0.0085/min, ~40x). New module:
`libraries/typescript/src/services/telephony-pricing-matrix.ts` and
`libraries/python/getpatter/services/telephony_pricing_matrix.py`. New
helper `parseE164Country` / `parse_e164_country` performs longest-prefix
lookup over the included tiny ISO map (no new dependencies). New setter
`CallMetricsAccumulator.setTelephonyContext` /
`CallMetricsAccumulator.set_telephony_context` lets the carrier handler
populate the context once direction and remote number are known.

Backward compatibility: omitting all new arguments keeps the legacy code
path intact and bills at `pricing["twilio"]["price"]` exactly as before
— no integration that didn't opt in changes its numbers.

Source: Twilio public pricing pages
(`https://www.twilio.com/en-us/voice/pricing/<iso2>`) as of 2026-05-12,
US-account perspective. Operators with negotiated Twilio rates should
override via
`new Patter({ pricing: { twilio_outbound_matrix: {...} } })` /
`Patter(pricing={"twilio_outbound_matrix": {...}})` (matrix shape:
`Record<iso2, { inbound?, outbound: { landline, mobile, tollfree? } }>`).

## 0.6.1 (2026-05-12)

### Changed — `StreamHandler` adopt-capability check now uses duck typing
Expand Down
13 changes: 13 additions & 0 deletions libraries/python/getpatter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,13 @@ def __getattr__(name):
calculate_tts_cost,
merge_pricing,
)
from getpatter.services.telephony_pricing_matrix import (
COUNTRY_CODE_TO_ISO2,
TWILIO_DEFAULT_FALLBACK_RATE,
TWILIO_PRICING_MATRIX,
parse_e164_country,
resolve_twilio_rate,
)

# Per-call metrics accumulator (TypeScript: ``CallMetricsAccumulator``).
from getpatter.services.metrics import CallMetricsAccumulator
Expand Down Expand Up @@ -504,6 +511,12 @@ def mix_pcm(agent: bytes, bg: bytes, ratio: float) -> bytes:
"calculate_tts_cost",
"calculate_realtime_cost",
"calculate_telephony_cost",
# Telephony pricing matrix (direction- and country-aware billing).
"TWILIO_PRICING_MATRIX",
"TWILIO_DEFAULT_FALLBACK_RATE",
"COUNTRY_CODE_TO_ISO2",
"parse_e164_country",
"resolve_twilio_rate",
# Per-call metrics.
"CallMetricsAccumulator",
# Observability extras.
Expand Down
46 changes: 41 additions & 5 deletions libraries/python/getpatter/pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,21 +644,57 @@ def calculate_llm_cost(


def calculate_telephony_cost(
provider: str, duration_seconds: float, pricing: dict
provider: str,
duration_seconds: float,
pricing: dict,
*,
direction: str | None = None,
dest_country: str | None = None,
dest_type: str | None = None,
) -> float:
"""Calculate telephony cost from call duration.

Twilio bills in whole-minute increments (any partial minute rounded up
per twilio.com/help/223132307). Telnyx bills per-second. Detection is
by provider name.
Twilio bills in whole-minute increments (any partial minute rounded
up per twilio.com/help/223132307). Telnyx bills per-second. Detection
is by provider name.

When ``direction`` and ``dest_country`` are supplied for the
``"twilio"`` provider, the rate resolves from the per-country
:data:`~getpatter.services.telephony_pricing_matrix.TWILIO_PRICING_MATRIX`
instead of the flat ``pricing["twilio"]["price"]`` default. Operators
can override the entire matrix per call by setting
``pricing["twilio_outbound_matrix"]`` to a free-form dict matching
the shape of ``TWILIO_PRICING_MATRIX``.

``dest_type`` defaults to ``"mobile"`` when omitted: international
mobile rates are universally higher than landline, and under-billing
is worse than over-billing on a margin-reporting dashboard.

Backward compatibility: omitting all three keyword arguments keeps
the legacy code path intact and bills at
``pricing["twilio"]["price"]`` exactly as before.
"""
import math

config = pricing.get(provider, {})
if config.get("unit") != "minute":
return 0.0
per_minute = config.get("price", 0.0)
if provider == "twilio" and direction and dest_country:
# Lazy import to keep ``pricing.py``'s import graph shallow —
# the matrix module is dependency-free so the cost is
# negligible.
from getpatter.services.telephony_pricing_matrix import resolve_twilio_rate

override_matrix = pricing.get("twilio_outbound_matrix")
per_minute = resolve_twilio_rate(
direction, # type: ignore[arg-type]
dest_country,
dest_type or "mobile", # type: ignore[arg-type]
override_matrix,
)
if provider == "twilio":
minutes = math.ceil(duration_seconds / 60.0)
else:
minutes = duration_seconds / 60.0
return minutes * config.get("price", 0.0)
return minutes * per_minute
51 changes: 50 additions & 1 deletion libraries/python/getpatter/services/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ def __init__(
# Actual provider costs (from post-call API queries)
self._actual_telephony_cost: float | None = None
self._actual_stt_cost: float | None = None
# Direction-aware telephony billing context. Populated by the
# telephony adapter (Twilio / Telnyx handler) once the call's
# direction and remote number are known. When unset, the legacy
# flat ``pricing["twilio"]["price"]`` rate is used so existing
# integrations bill identically to before 0.6.1's
# direction-aware matrix shipped.
self._telephony_direction: str | None = None
self._telephony_dest_country: str | None = None
self._telephony_dest_type: str | None = None
# LLM token usage accumulated across all turns (pipeline mode)
self._llm_total_input_tokens: int = 0
self._llm_total_output_tokens: int = 0
Expand Down Expand Up @@ -647,6 +656,41 @@ def set_actual_telephony_cost(self, cost: float) -> None:
"""
self._actual_telephony_cost = cost

def set_telephony_context(
self,
*,
direction: str | None = None,
dest_number: str | None = None,
dest_country: str | None = None,
dest_type: str | None = None,
) -> None:
"""Set direction-aware billing context for the telephony estimate.

Called by the Twilio / Telnyx adapter once direction and the
remote E.164 number are known. ``dest_number`` is parsed into an
ISO-2 country via :func:`parse_e164_country` — operators that
pre-compute the country can pass ``dest_country`` directly. Both
paths short-circuit when the country is unknown and the legacy
flat rate kicks in transparently.

All arguments are optional / nullable so adapters can call this
in a single pass with whatever subset of metadata they have.
Re-calling with a new value overrides the previous context for
the rest of the call.
"""
if direction is not None:
self._telephony_direction = direction
if dest_country is not None:
self._telephony_dest_country = dest_country.upper()
elif dest_number:
from getpatter.services.telephony_pricing_matrix import parse_e164_country

parsed = parse_e164_country(dest_number)
if parsed:
self._telephony_dest_country = parsed
if dest_type is not None:
self._telephony_dest_type = dest_type

def set_actual_stt_cost(self, cost: float) -> None:
"""Set the actual STT cost from the provider API (post-call).

Expand Down Expand Up @@ -943,7 +987,12 @@ def _compute_cost(self, duration_seconds: float) -> CostBreakdown:
telephony_cost = self._actual_telephony_cost
else:
telephony_cost = calculate_telephony_cost(
self.telephony_provider, duration_seconds, self._pricing
self.telephony_provider,
duration_seconds,
self._pricing,
direction=self._telephony_direction,
dest_country=self._telephony_dest_country,
dest_type=self._telephony_dest_type,
)

total = stt_cost + tts_cost + llm_cost + telephony_cost
Expand Down
Loading
Loading