Least-Cost Routing (LCR)¶
Route outbound calls across carriers by cost, with an external API making the decision and siphon executing it against its gateway health/failover machinery.
siphon is not a rating engine. The decision — which carrier, in what order, at what cost — is owned by an external HTTP JSON API you run (rate decks, prefix match, quality, margin, balance). siphon asks that API, caches the answer, and executes the ordered route set: it resolves each carrier's gateway pool to a healthy member, tries them cheapest-first with sequential failover, and stamps the winning carrier onto the CDR.
The split: API owns cost order (cached). siphon owns liveness + execution (healthy-member selection, dead-carrier skip, sequential failover, per-call CDR).
Why B2BUA-only (not proxy)¶
LCR in siphon is exposed only on the B2BUA (call.route(...)). There is no
proxy LCR path, on purpose. The decisive reason is dialog hygiene.
A proxy doing serial LCR is transparent end-to-end, so it keeps the same Call-ID toward every carrier it tries. That is the classic Kamailio serial-fork footgun:
- Ghost dialogs / double-connect. If carrier A actually set up state before you failed over (a 200 racing your CANCEL, a half-answer), carrier B now sees the same Call-ID. Some carriers reject it as a duplicate; worse, you can be billed on two carriers for one call.
- CDRs you can't separate. Every attempt shares one Call-ID, so "attempt to A (failed) / attempt to B (answered)" can't be told apart per carrier for ASR / billing.
- No mid-call control. In-dialog re-INVITE / BYE follow the established route set; the proxy isn't in the dialog, so it can't reroute or tear down on credit.
A B2BUA mints a fresh B-leg dialog — new Call-ID / From-tag / CSeq — per carrier attempt. Carriers never collide, per-carrier CDRs separate cleanly, and the B2BUA owns both dialogs (retry, per-carrier media, credit teardown). The customer-facing A-leg Call-ID stays stable regardless of which carrier wins.
B2BUA is also required for online charging (mid-call credit teardown) and per-carrier media/codec handling.
The flow¶
from siphon import b2bua, cdr, lcr, log
@b2bua.on_invite
async def route(call):
call.rewrite_identities("ims-e164@2026") # normalize the dialed number
decision = await lcr.route(call, trunk_group="cust-trunks")
if decision is None: # API down, no fallback
call.reject(503, "Route Unavailable")
return
if decision.reject: # API-side block
call.reject(decision.reject["code"], decision.reject["reason"])
return
if not decision.routes:
call.reject(404, "No Route")
return
call.route(decision.routes) # sequential failover
@b2bua.on_answer
def answered(call, reply):
route = call.active_route # the carrier that won
if route:
cdr.write(call, extra={"carrier_id": route.carrier_id,
"rate": f"{route.rate:.5f}",
"route_source": "lcr"})
@b2bua.on_failure
def failed(call, code, reason): # only after all carriers tried
call.reject(code, reason)
decision.routes is an ordered list[Route] — routing policy stays in Python,
so the script may filter or reorder (drop carriers over a rate ceiling, prefer a
region) before call.route(...). siphon resolves and dials from there.
Full example: examples/lcr_b2bua.py
+ .yaml. Reference API (FastAPI): examples/lcr_api_server.py.
Sequential failover¶
call.route([...]) tries carriers one at a time, in order:
- Dials the first routable carrier and arms its ring timeout (
timeout_secs, elsecall.route(timeout=…), else 30s). - On a carrier reject (4xx/5xx) or ring-timeout, advances to the next carrier — a fresh B-leg dialog each time.
- A
6xxfrom a carrier stops the sequence (global rejection, RFC 3261 §16.7 spirit). - The A-leg receives a failure only once every carrier is exhausted;
@b2bua.on_failurefires once, with the last carrier's response. - On answer,
call.active_routeis the carrier that won.
call.fork(strategy="sequential") uses the same engine for a bare target list
(this now actually fails over — previously the strategy was ignored). Captured
inbound flows (WebSocket connection reuse) are not carried on the sequential
path; use strategy="parallel" for WebSocket callees.
Gateway integration¶
A route names a gateway_group (a gateway: carrier pool). At dial time
siphon picks a healthy member (weighted/round-robin/hash per the group), and
skips the carrier entirely if the pool is down — health-probing you already
configured, no round-trip to the LCR API. A route may instead pin an explicit
next_hop, or override the whole Request-URI with ruri.
If the API names a gateway_group siphon doesn't know (a typo, or a group not
yet created), siphon warns and falls back to the route's next_hop if it has
one, else skips that carrier and fails over — never a silent hang. Groups don't
need a restart: gateway.add_group(...) / gateway.remove_group(...) add and
remove them at runtime from a script (e.g. a timer that syncs your carrier
inventory), and LCR routes that name them work immediately. Only YAML-defined
groups are fixed at boot.
Per-carrier shaping (prefix, headers)¶
Carriers want the number in different shapes:
tech_prefix— a dial/tech-prefix prepended to the R-URI userpart ("1010288","#31#"). Many carriers route or bill on a prefix in front of the E.164 number. siphon prepends it per carrier, so+12025550123becomes1010288+12025550123toward that carrier only.ruri— full Request-URI override when a carrier wants a specific number format or its own host.number_policy— a namednumber_policies:preset applied to this carrier's From/To/PAI, so the CLI/identity shape can differ per carrier (a failover to a second carrier reshapes independently). The R-URI stays owned bytech_prefix/ruri.headers— per-carrier headers injected on the B-leg INVITE (an account token, a routing tag), applied after the header policy so they always land.cdr_fields— key/value fields siphon auto-stamps onto the CDR when this carrier wins, so the API can push billing/routing metadata straight into the record without the script naming each field (cdr.write(extra=…)still overrides on a key clash).
{ "carrier_id": "carrier-a", "gateway_group": "carrier-a",
"tech_prefix": "1010288", "headers": { "X-Account": "42" }, "rate": 0.0042 }
Reroute causes (some carriers don't play nice)¶
Failover only happens on a reroute cause — a SIP code that means "this
carrier failed, try another", not "the call is over". A 486 Busy or 603
Decline is forwarded to the caller as-is (trying another carrier won't help); a
503/408 fails over.
The default reroute set is [408, 500, 502, 503, 504]. Override it at three
levels (most specific wins):
- Generic —
lcr.reroute_causesinsiphon.yaml. - Per-gateway —
gateway.groups[].reroute_causes, for a carrier that sends non-standard codes (e.g.404/403for "no circuits"). - Per-route — the API's
reroute_causeson aRoute, when the API knows a specific carrier misbehaves.
lcr:
reroute_causes: [408, 500, 502, 503, 504] # generic (this is also the default)
gateway:
groups:
- name: "carrier-x"
reroute_causes: [404, 408, 500, 503] # carrier-x sends 404 for no-circuits
destinations: [ ... ]
Caching and fallback¶
The LCR API is on the call-setup path, so:
- Decisions are cached in a named cache (
lcr.cache), keyed bytrunk_group:dialed_number, for the API-providedcache_ttl_secs(or the configured default). With a Redis-backed cache, a decision cached on one node is reused fleet-wide. Acache_ttl_secsof0/absent means don't cache. - A static fallback (
lcr.fallback_gateway_group) is used when the API is unreachable or times out, so routing degrades instead of every call failing. Without a fallback, an API failure surfaces to the script asNone(answer a 5xx).
Config¶
lcr:
api_url: "${LCR_API_URL:-http://127.0.0.1:8080/route}"
timeout_ms: 2000
cache: "lcr" # a name from the cache: list (optional)
cache_ttl_secs: 300 # default TTL when the API omits one
auth_header: "Bearer ${LCR_TOKEN}" # optional
fallback_gateway_group: "carrier-a" # optional
cache:
- name: "lcr"
url: "redis://127.0.0.1:6379"
local_ttl_secs: 60
gateway:
groups:
- name: "carrier-a"
probe: { enabled: true, interval_secs: 15, failure_threshold: 3 }
destinations:
- { uri: "sip:gw1.carrier-a.example:5060", address: "198.51.100.11:5060", weight: 2 }
- name: "carrier-b"
probe: { enabled: true }
destinations:
- { uri: "sip:gw1.carrier-b.example:5060", address: "203.0.113.21:5060" }
The JSON contract is in the LCR API reference; the
typed models operators build against ship in the siphon-sip SDK
(from siphon_sdk.lcr import LcrRequest, LcrResponse, Route).
Charging¶
The carrier that won is on call.active_route (a Route: carrier_id,
gateway_group, rate, currency), so it flows into all three charging paths:
- CDR. Stamp it into the record, as in the
@b2bua.on_answerhandler above:cdr.write(call, extra={"carrier_id": route.carrier_id, "rate": ...}). - Rf offline. Stamp the carrier's trunk group onto the offline record with
call.set_charging_param("outgoing-trunk-group-id", route.gateway_group). The Rf ACR auto-emit carries it asOutgoing-Trunk-Group-Id(TS 32.260). - Ro online. With the
ro:block enabled, SIPhon reserves credit before connect, re-authorises mid-call, and drops the call when the OCS refuses further credit (4012 CREDIT_LIMIT_REACHED). Because LCR is B2BUA-only, that mid-call teardown applies to every LCR call.
See the Online charging (OCS) recipe for the full Ro setup (reserve-before-connect gate, re-auth timer, CGRateS).