Least-Cost Routing (LCR)¶
An external HTTP JSON API owns the cost/order decision (siphon is not a rating engine); siphon caches it and executes the ordered route set as sequential failover, cheapest first, a fresh B-leg dialog per carrier. LCR is B2BUA-only — see the cookbook for why.
The script side is two calls:
from siphon import b2bua, lcr
@b2bua.on_invite
async def on_invite(call):
decision = await lcr.route(call, trunk_group="cust-trunks")
if decision is None: # API down and no fallback group
call.reject(503, "Service Unavailable")
return
if decision.reject: # API-side block
call.reject(decision.reject["code"], decision.reject["reason"])
return
call.route(decision.routes) # sequential failover
The rest of this page is the wire contract between siphon and that API,
followed by the lcr namespace and the
typed models the SDK ships. call.route(),
call.active_route and call.route_attempts are on the Call page.
The JSON contract (v1)¶
siphon POSTs an LcrRequest to lcr.api_url and expects an LcrResponse.
Typed models operators build against ship in the siphon-sip SDK:
A runnable reference server (FastAPI) is
examples/lcr_api_server.py.
Request (siphon → API)¶
POST {api_url} with Content-Type: application/json and the configured
auth_header (if any) as Authorization.
{
"version": "1",
"call_id": "abc123@10.0.0.1",
"from": "sip:+13105550100@sbc.example.com",
"to": "sip:+12025550123@sbc.example.com",
"dialed_number": "+12025550123",
"source": {
"ip": "192.0.2.50",
"trunk_group": "cust-trunks",
"transport": "udp"
},
"attributes": { "customer_id": "cust-42" }
}
| Field | Type | Notes |
|---|---|---|
version |
string | Contract version, currently "1". |
call_id |
string | A-leg Call-ID (for API-side correlation). |
from / to |
string | A-leg From / To URIs. |
dialed_number |
string | The number to rate — normalized by the script (canonical +E.164 recommended). |
source.ip |
string | A-leg source IP. |
source.trunk_group |
string? | Ingress trunk, if the script set it. Part of the cache key. Omitted when unset. |
source.transport |
string | udp | tcp | tls | ws | wss. |
attributes |
object | Free-form script hints. Omitted when empty. |
Response (API → siphon)¶
{
"routes": [
{ "carrier_id": "carrier-a", "gateway_group": "carrier-a",
"rate": 0.0042, "currency": "USD", "billing_increment": 60, "timeout_secs": 12 },
{ "carrier_id": "carrier-b", "next_hop": "sip:203.0.113.21:5060", "rate": 0.0051 }
],
"cache_ttl_secs": 300,
"reject": null
}
Ordered routes (cheapest / most-preferred first). Each route needs at least
one of gateway_group / next_hop / ruri.
| Route field | Type | Notes |
|---|---|---|
carrier_id |
string | Opaque id, carried into CDR/charging. Never routed on. |
gateway_group |
string? | A gateway: pool — siphon dials a healthy member, skips the route if the pool is down. Preferred (health-probed). |
next_hop |
string? | Explicit next-hop URI (when no group, or to pin the wire destination). |
ruri |
string? | Full Request-URI override (carrier IMPU shape / number format). |
destination |
string? | Retarget this carrier at a different destination number (RFC 3261 §16.5). Overrides the answer-level destination. Bare number or a URI (userpart only). |
tech_prefix |
string? | Dial/tech-prefix prepended to the R-URI userpart for this carrier (e.g. "1010288"), after number_policy has shaped the number. |
number_policy |
string? | Named number_policies: preset for this carrier's B-leg: shapes the dialled number in the R-URI and the From/To/PAI identity alike. Absent = b2bua.default_number_policy, as for call.dial(). A name that is not configured shapes nothing and is logged at warn. |
caller_id |
string? | Calling number this carrier is presented (the CLI). Tag-preserving; also applied to PAI/PPI, and a PAI is asserted when the leg has none (b2bua.assert_identity). |
caller_id_presentation |
string? | allowed (default) or restricted — CLIR per RFC 3323 / TS 24.607. |
rate |
number? | Per-minute rate (CDR/charging). |
currency |
string? | ISO 4217. |
billing_increment |
int? | Seconds (60 = per-minute, 1 = per-second). |
min_duration |
int? | Minimum billable seconds. |
timeout_secs |
int? | Per-attempt ring timeout (else the call-level default). Bounds the wait for the carrier to show progress, not for its answer. See ring timeout and progress. |
headers |
object? | Headers to inject on this carrier's B-leg INVITE (applied after the header policy). Dialog headers are refused — see below. |
cdr_fields |
object? | Fields siphon auto-stamps onto the CDR when this carrier wins (no per-field script). |
reroute_causes |
int[]? | SIP codes from this carrier that fail over to the next (overrides per-gateway + global). |
reroute_after_progress |
bool? | true fails this carrier over at timeout_secs even after it has sent a 101-199, for a carrier that plays its own ringback before it has reached anyone. Default false. Omitted when false. |
Top-level:
| Field | Type | Notes |
|---|---|---|
routes |
array | Ordered carriers. Empty + reject: null = no route (script answers 4xx/5xx). |
cache_ttl_secs |
int? | How long siphon may cache this decision. 0/absent = do not cache. |
reject |
object? | { "code": int, "reason": string } — siphon rejects the call with this instead of routing (API-side block). |
destination |
string? | Retarget the call at a different destination number before routing (RFC 3261 §16.5). Applies to every route that does not set its own. |
Behavior notes¶
- Caching — keyed by
{trunk_group}:{dialed_number}inlcr.cache, forcache_ttl_secs(orlcr.cache_ttl_secs). Redis-backed caches are shared fleet-wide. - Fallback — on transport error / timeout / 5xx, siphon uses
lcr.fallback_gateway_groupif set (a synthesized single-route decision), else the script seesNone. - Reroute — a carrier failure fails over to the next carrier only when its
code is a reroute cause (per-route
reroute_causes> per-gatewaygateway.groups[].reroute_causes> globallcr.reroute_causes, default[408, 500, 502, 503, 504]). A definitive response (486, 603) is forwarded to the caller. When the sequence moves on and none of the carriers left can be dialled (no healthy gateway group member, next-hop orruri, or an INVITE that could not be sent), each is recorded as its own503attempt withdialed: false, and the call fails503whatever the carrier before them sent, a ring-out after progress included. - Ring timeout and progress:
timeout_secsbounds how long siphon waits for a carrier to show progress, not how long it waits for the answer. Progress is any provisional from 101 to 199 from the carrier in flight. A100 Tryingis hop by hop and does not count. It is the line RFC 3261 §16.7 step 2 draws for a proxy's Timer C.
A carrier that has shown nothing by timeout_secs is CANCELled and the next
one is dialled. When there is no next one to dial, or the route does not
reroute on 408, the call fails with 503 Service Unavailable instead: no
carrier reached the callee. A carrier that has sent a 180 or 183 is ringing the
callee, so it keeps the call: its deadline moves to the later of its own
timeout_secs and the sequence's ring bound (call.route(timeout=…), 30 s by
default), both counted from when that carrier was dialled. If that passes too,
siphon CANCELs the carrier and fails the call with 408 without trying the
rest, and @b2bua.on_failure runs as for any other failure. A final failure
after progress (a 503, say) still fails over as usual.
Every ring timeout is a failed attempt, whether the sequence goes on or ends
on it: it is recorded once as 408 on call.route_attempts (and the CDR's
lcr_attempts), and @b2bua.on_route_failure fires once for it, before
@b2bua.on_failure when the call fails there. That 408 is the carrier's
outcome; the caller and @b2bua.on_failure get 503 or 408 as above.
Some carriers answer 183 with ringback they generate themselves before they
have reached anyone. Give such a carrier reroute_after_progress: true and it
fails over at timeout_secs whatever it has sent:
{ "carrier_id": "carrier-x", "gateway_group": "carrier-x",
"timeout_secs": 6, "reroute_after_progress": true }
headers cannot forge a dialog header — siphon owns the B-leg dialog, so
Via, Call-ID, CSeq, Max-Forwards, Content-Length, From, To,
Contact, Record-Route and Route are refused from a route's headers and
logged at warn naming the carrier and the header. This is the same set no
header policy may touch. Overwriting one would not fail visibly — a From
from a route replaces the header including its dialog tag, the INVITE still
goes out, and the breakage surfaces later as ACKs and BYEs that no longer
match. Use number_policy to reshape the From/To identity per carrier.
Proxy-Authorization is not refused, so a per-carrier trunk credential
still works.
- Retargeting: destination vs ruri — both change what the carrier is
asked to reach, but at different layers.
destination replaces the dialled number before the ordinary dial path
runs, so tech_prefix, number_policy and gateway-group member selection all
still apply on top. Given as a bare number ("+12025550123") or a full URI, of
which only the userpart is taken — the host stays siphon's to decide, from the
group or the next-hop, so a retarget can never send the call somewhere the
operator did not configure.
ruri replaces the whole Request-URI, host included. That means composing
each carrier's URI by hand, which bypasses group member selection and health
checking. Reach for it only when the host genuinely has to differ per route.
Both can be set: ruri owns the host, destination owns the number. A
destination alone does not make a route routable — it says who to reach,
never how, so the route still needs a gateway_group or a next_hop.
{
"destination": "+12025550199",
"routes": [
{ "carrier_id": "a", "gateway_group": "carriers", "tech_prefix": "1010288" },
{ "carrier_id": "b", "gateway_group": "carriers", "destination": "+12025550188" }
]
}
With no number policy in play, carrier a is dialled as
1010288+12025550199 through a healthy member of carriers; carrier b
overrides the number with its own. Under a plain policy carrier a would be
dialled as 101028812025550199: the policy shapes the retargeted number
first, and the prefix goes in front of the result.
The To userpart follows the retarget, so the number the call was originally
dialled on never reaches the carrier. The tech prefix is not applied to
To: it is a carrier routing artifact that belongs to the R-URI, not to the
called-party identity. number_policy still owns To's format on top.
- Presented CLI and CLIR — caller_id substitutes the calling number this
carrier sees, on From and on P-Asserted-Identity / P-Preferred-Identity.
It goes through the tag-preserving identity path, which is why
it is a field rather than something for headers: a From written without
its dialog tag breaks every subsequent in-dialog request, and only surfaces
later, on the ACK. number_policy reshapes the format of whatever number is
present; caller_id substitutes a different one, which a policy cannot do.
caller_id_presentation: "restricted" withholds the identity (RFC 3323 §4.1,
3GPP TS 24.607):
Frombecomes"Anonymous" <sip:anonymous@anonymous.invalid>, tag intactPrivacy: idis asserted (RFC 3325 §7), appended to any existing valueP-Asserted-Identitycarries the real identity to the trusted next hop — that is how the network stays able to identify the caller for regulatory and emergency purposes. It is asserted from theFromwhen the leg has none, which is the usual case on a B-leg: the header policy stripsP-*at the trust boundary, soPrivacy: idwould otherwise be a privacy request with nothing behind itP-Preferred-Identityis removed: it is the UA's request for what to assert, and forwarding it past a privacy boundary re-leaks the number
The two always move together. Asserting Privacy: id while leaving the real
number in From leaks it to every carrier that renders From rather than
PAI, which defeats CLIR while looking like it works.
Ordering is fixed, and each step needs the one before it: caller_id is
substituted first, the identity is asserted second (so the PAI carries the
presented number, not the caller's own), number_policy reshapes formats
third (so the PAI and the From agree), and anonymisation runs last (so a
restricted route naming no caller_id still asserts the real identity, and no
policy tries to reformat anonymous as a number). The assertion is
b2bua.assert_identity, on by default; turn it off for a next hop genuinely
outside the trust domain. An unrecognised caller_id_presentation is logged
and treated as restricted, because a withheld call going out with the real
number is the failure that matters.
The script-level twins are call.set_caller_id(number) and
call.restrict_caller_id(), for deployments not using the LCR API.
- Forward-compatibility — unknown response fields are ignored; new optional
fields can be added without a version bump. Bump
versiononly on a breaking change.
lcr namespace¶
The B2BUA-only routing namespace. await lcr.route(call, …) queries the API
(through the decision cache, falling back to lcr.fallback_gateway_group), and
returns an LcrDecision, or None when the API is unreachable
and no fallback is configured.
Mock lcr namespace — B2BUA-only Least-Cost Routing.
Configure the canned decision the next await lcr.route(call) returns::
lcr = mock_module.get_lcr()
lcr.set_routes([Route(carrier_id="a", gateway_group="pool-a", rate=0.004)])
# or: lcr.set_reject(503, "No Route") # API-side block
# or: lcr.set_unavailable() # route() returns None
Assert on what the script asked via lcr.queries.
LcrDecision¶
What await lcr.route(call) returns: the ordered carrier routes, or an
API-side reject to answer the call with. reject and a non-empty routes are
mutually exclusive; both empty means the API had no route and the script
decides the response itself.
Mock of the decision returned by await lcr.route(call).
Mirrors the Rust LcrDecision: .routes is a list[Route] and
.reject is a {"code": int, "reason": str} dict or None.
Typed contract models¶
siphon_sdk.lcr is the typed source for the wire contract above — the same
shapes the Rust serde structs use, for operators implementing the API in
Python. to_dict() omits None / empty fields to match the Rust
skip_serializing_if, and aliases from_uri / to_uri onto the JSON from /
to keys (Python reserved words).
LcrRequest¶
The query siphon POSTs to the LCR API for each new call.
LcrSource¶
LcrResponse¶
The ordered decision the LCR API returns to siphon.
routes
class-attribute
instance-attribute
¶
Carriers to try, cheapest/most-preferred first. Empty + reject=None
means "no route".
cache_ttl_secs
class-attribute
instance-attribute
¶
How long (seconds) siphon may cache this decision. None / 0 = do
not cache. The API fully controls caching via this field.
reject
class-attribute
instance-attribute
¶
When set, siphon rejects the call with this code/reason instead of dialing (an API-side block).
destination
class-attribute
instance-attribute
¶
Retarget the call at a different destination number before routing
(RFC 3261 §16.5). Applies to every route that does not override it with its
own :attr:Route.destination.
tech_prefix, number_policy and gateway-group member selection all
apply on top of it, unchanged.
resolved_routes
¶
Routes with the answer-level :attr:destination resolved onto every
route that does not carry its own.
Mirrors what siphon does before dialing, so a test can assert the destination each carrier will actually be asked to reach.
Route¶
One carrier attempt in an :class:LcrResponse.
At least one of :attr:gateway_group / :attr:next_hop / :attr:ruri
must be set for the route to be routable.
carrier_id
instance-attribute
¶
Opaque carrier identifier — carried into CDR/charging, never routed on.
gateway_group
class-attribute
instance-attribute
¶
Configured gateway: group to route through. siphon resolves it to a
healthy member at dial time and skips the route if the whole group is down.
Preferred over :attr:next_hop so carrier health-probing applies.
next_hop
class-attribute
instance-attribute
¶
Explicit next-hop URI (used when no :attr:gateway_group, or to pin the
wire destination while :attr:ruri shapes the Request-URI).
ruri
class-attribute
instance-attribute
¶
Request-URI override for this carrier (else the dialed number is kept).
Replaces the whole Request-URI, host included, so the API has to compose
the carrier's URI itself — which bypasses gateway-group member selection and
health checking. Prefer :attr:destination when only the number needs to
change.
destination
class-attribute
instance-attribute
¶
Retarget this carrier's attempt at a different destination number,
overriding the answer-level destination (RFC 3261 §16.5).
Replaces the dialled number before the ordinary dial path runs, so
:attr:tech_prefix, :attr:number_policy and gateway-group member
selection all still apply on top. Accepts a bare number
("+12025550123") or a full URI, of which only the userpart is taken —
the host is siphon's to decide, so a retarget can never route the call
somewhere the operator did not configure.
A destination alone does not make a route routable: it says who to
reach, never how.
tech_prefix
class-attribute
instance-attribute
¶
Tech-prefix / dial-prefix prepended to the B-leg R-URI userpart for this
carrier (e.g. "1010288"). Many carriers key routing/billing on a prefix
in front of the E.164 number. Prepended after :attr:number_policy has
shaped the number, and never applied to To.
rate
class-attribute
instance-attribute
¶
Per-minute rate — carried into CDR/charging, not used for routing.
currency
class-attribute
instance-attribute
¶
Rate currency (ISO 4217), e.g. "USD".
billing_increment
class-attribute
instance-attribute
¶
Billing increment in seconds (60 = per-minute, 1 = per-second).
min_duration
class-attribute
instance-attribute
¶
Minimum billable duration in seconds.
timeout_secs
class-attribute
instance-attribute
¶
Per-attempt ring timeout in seconds (else the call-level default).
caller_id
class-attribute
instance-attribute
¶
Calling number this carrier should see (the presented CLI), or None
to keep the caller's own.
Applied through the same tag-preserving path that reshapes identity headers,
so the B-leg's From tag survives — which is why this is a field rather
than something to put in :attr:headers, where a From is refused
precisely because it would take the dialog tag with it.
:attr:number_policy reshapes the format of whatever number is present;
this substitutes a different one, which a number policy cannot do.
Also applied to P-Asserted-Identity / P-Preferred-Identity when the
message carries them.
caller_id_presentation
class-attribute
instance-attribute
¶
"allowed" (default) or "restricted" — whether the calling
identity may be presented to this carrier (CLIR).
restricted applies RFC 3323 §4.1 / TS 24.607: the From becomes
"Anonymous" <sip:anonymous@anonymous.invalid> with its tag intact,
Privacy: id is asserted, P-Preferred-Identity is dropped, and
P-Asserted-Identity keeps the real identity for the trusted next hop
(RFC 3325 §7).
Asserting Privacy: id without anonymising the From leaks the number
to every carrier that renders From rather than PAI, which defeats CLIR
while looking like it works — so the two always move together.
number_policy
class-attribute
instance-attribute
¶
Named number_policies: preset applied to this carrier's B-leg: the
dialled number in the R-URI and the identity headers (From/To/PAI) get the
same shape, and :attr:tech_prefix is prepended to the shaped number. When
None, b2bua.default_number_policy applies, as it does for
call.dial(). A name that is not configured shapes nothing and is
logged.
headers
class-attribute
instance-attribute
¶
Headers to inject on this carrier's B-leg INVITE (account token, routing tag). Applied after the header policy, so they always land on the wire.
cdr_fields
class-attribute
instance-attribute
¶
Fields siphon auto-stamps onto the CDR when this carrier wins — push billing/routing metadata straight into the record without naming each field in the script.
reroute_causes
class-attribute
instance-attribute
¶
SIP codes from this carrier that fail over to the next (overrides the per-gateway and global sets). For a carrier that sends non-standard codes.
reroute_after_progress
class-attribute
instance-attribute
¶
Fail this carrier over when :attr:timeout_secs passes, even after it
has shown progress.
A route's timeout_secs bounds the wait for the carrier to show
progress: any provisional from 101 to 199, the line RFC 3261 §16.7 step 2
draws for a proxy's Timer C. Once a carrier has, it keeps the call until
the later of its own timeout and the sequence's ring bound
(call.route(timeout=…)), both counted from its dial, and the call then
fails with 408 instead of going to the next carrier.
A carrier that answers 183 with ringback of its own before it has reached
anyone shows progress it does not have; True puts that one carrier back
on failing over at timeout_secs. Omitted from the JSON when False.
is_routable
¶
A route is routable if it names a gateway group, next-hop, or R-URI.