Skip to content

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, else call.route(timeout=…), else 30s).
  • On a carrier reject (4xx/5xx), or a ring-timeout before the carrier has shown progress, advances to the next carrier, a fresh B-leg dialog each time. A carrier that has sent a 180/183 keeps the call past its timeout_secs; see ring timeout and progress.
  • A 6xx from a carrier stops the sequence (global rejection, RFC 3261 §16.7 spirit).
  • The A-leg receives a failure only once every carrier is exhausted, and it is the best of the carriers' failures (RFC 3261 §16.7), not the last one: a carrier that rang out (408) outranks a later one's 503, which in turn reaches the caller as a 500. @b2bua.on_failure fires once, with that code, and what it decides is carried out: call.reject() picks the caller's response, and call.dial() or a fresh call.route() tries somewhere else (a backup trunk, voicemail).
  • When the sequence ends on a ring timeout instead (the last carrier rang out, or one whose route does not reroute on 408), siphon answers the caller itself. If that carrier never sent a 180/183, no carrier got as far as the callee and the caller gets 503 Service Unavailable, a 503 of siphon's own that is not turned into 500. If it had, the callee was reached and did not answer, and the caller gets 408. @b2bua.on_failure gets the same code. The attempt itself is recorded as 408 either way.
  • When the sequence moves on and none of the carriers left can be dialled, it ends on them: the caller and @b2bua.on_failure get siphon's own 503, whatever the carrier before them did. A carrier with reroute_after_progress that rang and then rang out gives 503 here, not 408, and a carrier's 503 is not turned into 500. Each of those carriers is its own 503 attempt, with dialed: False. A sequential control-plane dial reports the same code in DialFailed.
  • On answer, call.active_route is the carrier that won.
  • call.route_attempts lists the carriers it burned to get there — one entry per failed attempt (carrier_id, status, elapsed_ms, dialed), oldest first. Empty when the first carrier answered.
  • A carrier siphon could not reach (its gateway group unknown or entirely down, or its destination would not resolve) is skipped immediately — the sequence does not wait out that carrier's ring timeout for an INVITE it never sent. It is still recorded, with dialed: False.

Seeing a failover happen

A failover is an operational event, so it is reported at info: siphon logs each failed attempt with its carrier, status and elapsed time, and logs the advance to the next carrier.

Two ways to keep it rather than just read it:

@b2bua.on_route_failure
def carrier_failed(call, route, code):
    """Fires once per failed attempt, including the last."""
    if code in (408, 503):                      # what YOU count as the carrier's fault
        carrier_failures.labels(carrier=route.carrier_id).inc()

@b2bua.on_answer
def answered(call, reply):
    for attempt in call.route_attempts:
        log.warn(f"burned {attempt['carrier_id']} "
                 f"{attempt['status']} after {attempt['elapsed_ms']}ms")

@b2bua.on_route_failure fires for every non-2xx a carrier returns — a definitive 486 Busy as much as a 503, and a ring timeout as 408. That is the same set call.route_attempts records, so the two never disagree; filter on code for what you actually treat as a carrier health signal. It is purely a notification: the failover decision is already made, and raising in it does not change the call.

Not every burned carrier is the carrier's fault

An attempt's dialed says whether siphon actually put an INVITE on the wire for that carrier. False means it did not — the gateway group was unknown or entirely down, or the next-hop would not resolve — so status (503) is siphon's own verdict on the route, not something the carrier said. The carrier never saw the call.

Filter on it before counting a failure against a carrier. A stale DNS name or a down gateway group is a local configuration problem, and trending it as carrier quality sends you to the carrier with figures they cannot reconcile:

@b2bua.on_route_failure
def carrier_failed(call, route, code):
    attempt = call.route_attempts[-1]
    if not attempt["dialed"]:
        # siphon never reached this carrier — alert your own config, not them.
        unroutable_carriers.labels(carrier=route.carrier_id).inc()
        return
    if code in (408, 503):
        carrier_failures.labels(carrier=route.carrier_id).inc()

siphon logs an undialled carrier at info too (LCR: carrier burned without dialling), so it is visible without a handler.

When cdr.auto_emit is on, the same list is stamped onto the CDR as lcr_attempts (a compact JSON array), alongside the winning carrier's cdr_fields — so a completed call that burned a carrier records that in the billing pipeline instead of only in the log.

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. A sequential fork is a hunt through phones, and every phone that rings sends a 180, so its targets move on when their timeout passes whether they rang or not: the progress rule below applies to LCR carriers, not to fork targets. A sequential dial on the control plane hunts the same way.

Ring timeout and progress

A route's timeout_secs bounds how long siphon waits for the 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. A 100 Trying is hop by hop and does not count. That is the same line RFC 3261 §16.7 step 2 draws for a proxy's Timer C: once the next hop sends something past a 100, it is working on the request.

  • No progress by timeout_secs: siphon CANCELs the carrier and dials the next one, fast failover for a carrier that is down or black-holing calls. With no next carrier to dial, or a route that does not reroute on 408, the call fails with 503, since no carrier reached the callee. The attempt is still a 408.
  • Progress before it: the carrier is ringing the callee, and cutting it off would drop the caller mid-ring onto a carrier that has to start again. 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, so progress never shortens a ring. If that deadline passes too, siphon CANCELs the carrier and fails the call with 408 without trying the remaining carriers. The attempt goes on call.route_attempts as 408 and @b2bua.on_route_failure fires for it first, as for any carrier that rings out. Then @b2bua.on_failure runs as for any other failure and can still route the call somewhere else.
  • A final failure after progress (a 503, say) still fails over as usual. Only the timeout changes.
  • With call.route(timeout=0) there is no ring bound, so a carrier that has shown progress rings until it answers, fails or the caller hangs up.
call.route(decision.routes, timeout=45)   # a ringing carrier gets up to 45 s
{ "carrier_id": "carrier-a", "gateway_group": "carrier-a", "timeout_secs": 6 }

Here carrier-a has 6 s to send a 180/183. If it does, it rings for up to 45 s from its dial before the call fails with 408.

Some carriers answer 183 with ringback they generate themselves, before they have reached anyone. That reads as progress it does not have, so the API can put one carrier back on failing over at timeout_secs whatever it has sent:

{ "carrier_id": "carrier-x", "gateway_group": "carrier-x",
  "timeout_secs": 6, "reroute_after_progress": true }

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:

  • number_policy — a named number_policies: preset for this carrier. It shapes the dialled number in the Request-URI and the identity headers (From/To/PAI) alike, so the two never disagree, and a failover to a second carrier reshapes independently. A route that names none gets b2bua.default_number_policy, the same as call.dial(). A name that is not configured shapes nothing and is logged at warn with the carrier.
  • tech_prefix — a dial/tech-prefix ("1010288", "#31#") prepended to the Request-URI userpart per carrier, after number_policy has shaped the number. Many carriers route or bill on a prefix in front of the number: under a plain policy +12025550123 goes to that carrier as 101028812025550123, and with no policy at all it keeps the shape it arrived in (1010288+12025550123). The prefix never reaches To.
  • ruri — full Request-URI override when a carrier wants its own host. The number in it is still shaped by the route's policy, when there is one.
  • 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 }

Presented CLI and CLIR (per carrier)

Two more route fields decide what the calling party looks like to that carrier. number_policy above reshapes the format of whatever number is already there; these two substitute a different one, and withhold it.

  • caller_id — the number this carrier is presented, on From and on P-Asserted-Identity / P-Preferred-Identity. It is a field rather than something for headers because it goes through the tag-preserving identity path: a From written by hand loses the dialog tag, the INVITE still goes out, and the breakage only surfaces later on the ACK.

    The PAI is inserted when the leg has none, which is the usual case: the header policy strips P-* off an untrusted access leg, correctly, so there is nothing left to rewrite by the time a route's caller_id is applied. siphon asserts its own rather than relaying the UE's (b2bua.assert_identity, on by default — set it to false for a next hop genuinely outside the trust domain). - caller_id_presentation"allowed" (the default) or "restricted" for CLIR (RFC 3323 §4.1, 3GPP TS 24.607). restricted does four things together: - From becomes "Anonymous" <sip:anonymous@anonymous.invalid>, tag intact - Privacy: id is asserted (RFC 3325 §7), appended to any existing value - P-Asserted-Identity carries the real identity to the trusted next hop, so the network can still identify the caller for regulatory and emergency purposes — asserted from the From just before it is anonymised when the leg has none, because Privacy: id over an absent PAI is a privacy request the next hop cannot honour and a regulatory gap at once - P-Preferred-Identity is removed, since it is the UA's request for what to assert and forwarding it past a privacy boundary re-leaks the number

    They move together on purpose. 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.

{
  "routes": [
    { "carrier_id": "carrier-a", "gateway_group": "carrier-a",
      "caller_id": "+13105550100" },
    { "carrier_id": "wholesale-b", "gateway_group": "wholesale-b",
      "caller_id": "+13105550100", "caller_id_presentation": "restricted" }
  ]
}

Both are per route, not per response — unlike destination, there is no answer-level default for a carrier to inherit. A failover from carrier A to carrier B therefore never carries A's presentation across by accident; a route that wants CLIR has to say so. Set it on every route in the sequence when the call is withheld, or the call goes out anonymous on the cheapest carrier and with the real number on the failover.

Ordering inside one route is fixed: caller_id substitutes first, the identity is asserted second, number_policy reshapes formats third, and anonymisation runs last. Each step depends on the one before it. Asserting after the substitution is what puts the presented number in the PAI rather than the caller's own; asserting before the number policy is what keeps the PAI and the From in the same format; and asserting before the anonymisation is what gives Privacy: id a real identity to withhold on a restricted route that names no caller_id. No policy ever tries to reformat anonymous as a number. 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.

For deployments not using the LCR API the script-level twins are call.set_caller_id(number) and call.restrict_caller_id(), called before call.dial() / call.route().

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):

  • Genericlcr.reroute_causes in siphon.yaml.
  • Per-gatewaygateway.groups[].reroute_causes, for a carrier that sends non-standard codes (e.g. 404/403 for "no circuits").
  • Per-route — the API's reroute_causes on a Route, 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 by trunk_group:dialed_number, for the API-provided cache_ttl_secs (or the configured default). With a Redis-backed cache, a decision cached on one node is reused fleet-wide. A cache_ttl_secs of 0/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 as None (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_answer handler above: cdr.write(call, extra={"carrier_id": route.carrier_id, "rate": ...}). With cdr.auto_emit on, that attaches to the record siphon is already keeping for the call — the carrier fields and the call's duration land on one row, not two.
  • 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 as Outgoing-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).