Skip to content

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:

from siphon_sdk.lcr import LcrRequest, LcrResponse, Route, LcrReject

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} in lcr.cache, for cache_ttl_secs (or lcr.cache_ttl_secs). Redis-backed caches are shared fleet-wide.
  • Fallback — on transport error / timeout / 5xx, siphon uses lcr.fallback_gateway_group if set (a synthesized single-route decision), else the script sees None.
  • Reroute — a carrier failure fails over to the next carrier only when its code is a reroute cause (per-route reroute_causes > per-gateway gateway.groups[].reroute_causes > global lcr.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 or ruri, or an INVITE that could not be sent), each is recorded as its own 503 attempt with dialed: false, and the call fails 503 whatever the carrier before them sent, a ring-out after progress included.
  • Ring timeout and progress: timeout_secs bounds 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. A 100 Trying is 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 CLIRcaller_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):

  • 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 — that is how the network stays able to identify the caller for regulatory and emergency purposes. It is asserted from the From when the leg has none, which is the usual case on a B-leg: the header policy strips P-* at the trust boundary, so Privacy: id would otherwise be a privacy request with nothing behind it
  • P-Preferred-Identity is 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 version only 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.

from siphon import lcr

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.

set_routes

set_routes(routes) -> None

Canned ordered carrier routes for subsequent route() calls.

set_reject

set_reject(code: int, reason: str) -> None

Make route() return a decision carrying an API-side reject.

set_unavailable

set_unavailable() -> None

Make route() return None (API unreachable, no fallback).

route async

route(call, trunk_group=None, attributes=None)

Return the configured decision (or None), recording the query.

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

from siphon_sdk.lcr import LcrRequest, LcrSource, LcrResponse, Route, LcrReject

LcrRequest

The query siphon POSTs to the LCR API for each new call.

call_id instance-attribute

call_id: str

A-leg Call-ID (for the API's own correlation).

from_uri instance-attribute

from_uri: str

A-leg From URI. Serializes to the JSON key "from".

to_uri instance-attribute

to_uri: str

A-leg To URI. Serializes to the JSON key "to".

dialed_number instance-attribute

dialed_number: str

The number being dialed, normalized by the script (canonical +E.164 recommended) — this is what the API rates.

source instance-attribute

source: LcrSource

Ingress context.

version class-attribute instance-attribute

version: str = CONTRACT_VERSION

Contract version.

attributes class-attribute instance-attribute

attributes: Dict[str, str] = field(default_factory=dict)

Free-form script-supplied hints (customer id, rate-deck id, …).

LcrSource

Ingress context on an :class:LcrRequest.

ip instance-attribute

ip: str

Source IP of the A-leg.

transport class-attribute instance-attribute

transport: str = 'udp'

A-leg transport: "udp" | "tcp" | "tls" | "ws" | "wss".

trunk_group class-attribute instance-attribute

trunk_group: Optional[str] = None

Ingress trunk / customer group the call arrived on, if known. Part of the decision cache key.

LcrResponse

The ordered decision the LCR API returns to siphon.

routes class-attribute instance-attribute

routes: List[Route] = field(default_factory=list)

Carriers to try, cheapest/most-preferred first. Empty + reject=None means "no route".

cache_ttl_secs class-attribute instance-attribute

cache_ttl_secs: Optional[int] = None

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

reject: Optional[LcrReject] = None

When set, siphon rejects the call with this code/reason instead of dialing (an API-side block).

destination class-attribute instance-attribute

destination: Optional[str] = None

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

resolved_routes() -> List[Route]

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

carrier_id: str

Opaque carrier identifier — carried into CDR/charging, never routed on.

gateway_group class-attribute instance-attribute

gateway_group: Optional[str] = None

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

next_hop: Optional[str] = None

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

ruri: Optional[str] = None

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

destination: Optional[str] = None

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: Optional[str] = None

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

rate: Optional[float] = None

Per-minute rate — carried into CDR/charging, not used for routing.

currency class-attribute instance-attribute

currency: Optional[str] = None

Rate currency (ISO 4217), e.g. "USD".

billing_increment class-attribute instance-attribute

billing_increment: Optional[int] = None

Billing increment in seconds (60 = per-minute, 1 = per-second).

min_duration class-attribute instance-attribute

min_duration: Optional[int] = None

Minimum billable duration in seconds.

timeout_secs class-attribute instance-attribute

timeout_secs: Optional[int] = None

Per-attempt ring timeout in seconds (else the call-level default).

caller_id class-attribute instance-attribute

caller_id: Optional[str] = None

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

caller_id_presentation: Optional[str] = None

"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

number_policy: Optional[str] = None

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: Dict[str, str] = field(default_factory=dict)

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

cdr_fields: Dict[str, str] = field(default_factory=dict)

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

reroute_causes: List[int] = field(default_factory=list)

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

reroute_after_progress: bool = False

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

is_routable() -> bool

A route is routable if it names a gateway group, next-hop, or R-URI.

LcrReject

An API-side instruction to reject the call instead of routing it.

code instance-attribute

code: int

SIP status code (e.g. 503, 403).

reason instance-attribute

reason: str

SIP reason phrase.