# Build Observable AI Model Fallbacks with Telnyx AI Gateway

> Source: <https://dev.to/sonam_gupta_dba00ec9473e5/build-observable-ai-model-fallbacks-with-telnyx-ai-gateway-3ken>
> Published: 2026-10-20 02:08:30+00:00

An AI model fallback is only useful if your application can answer two questions afterward:

A client-side retry loop can hide both answers. The application eventually receives a successful response, but the path to that response is difficult to reconstruct. SDK retries can also stack on top of gateway retries, creating extra requests and misleading attempt counts.

The [`gateway-fallbacks`](https://github.com/team-telnyx/telnyx-code-examples/tree/main/gateway-fallbacks) Python sample moves that resilience policy into Telnyx AI Gateway. It provisions a primary and fallback model, forces a timeout on the primary, and proves the resulting failover twice:

The example separates management, inference, and observation:

```
Management plane                         Inference plane
api.telnyx.com/v2/llm_token_gateway     llm.telnyx.com/v1
Telnyx account API key                  scoped token key

POST /token_groups                      GET /models
POST /token_keys                        POST /chat/completions
GET  /spend/events                             |
GET  /usage/summary                            v
        |                               gateway retries and fallbacks
        +----------------------+----------------+
                               |
                               v
Observation: x-ltg-served-model, x-ltg-attempts,
             requested_model, model, attempts, cache_hit
```

The management plane owns token groups, token keys, budgets, and usage reporting. It uses the Telnyx account API key.

The inference plane is OpenAI-compatible. It uses only the token key issued for the group, so application code does not need the account credential.

The sample provisions two allowed model aliases and one fallback relationship:

```
allowed_models = ["Kimi-K3", "GLM-5.3-Flash"]
fallbacks = {
    "Kimi-K3": ["GLM-5.3-Flash"],
}
retries = {
    "max_attempts": 2,
    "retry_delay_ms": 250,
    "backoff": "exponential",
}

group = client.llm_token_gateway.token_groups.create(
    name="gateway-fallbacks-demo",
    allowed_models=allowed_models,
    fallbacks=fallbacks,
    retries=retries,
    max_budget=10,
    budget_duration="1d",
    idempotency_key=f"gateway-fallbacks-group-{uuid.uuid4()}",
).data
```

The group constrains which models its key can call and defines how failures move through the chain. The sample validates the same policy limits locally before making the API request:

`max_attempts` must be between one and five`allowed_models`
`max_attempts × (1 + longest chain)` cannot exceed eight
The gateway also enforces these rules. `policy.py` deliberately sends an invalid `max_attempts=9` update and expects an HTTP `400` without mutating the group.

The gateway owns retries and model fallbacks, so the OpenAI client disables its own retry behavior:

```
llm = OpenAI(
    base_url="https://llm.telnyx.com/v1",
    api_key=token_key,
    max_retries=0,
)
```

This matters because an SDK retry is a new request layered on top of the gateway's attempts. Disabling it keeps the response headers, ledger records, and billing aligned around one retry policy.

The sample distinguishes failures where no model work occurred from failures where processing may already have started.

**Definite no-work failures** such as pre-dispatch errors, `429`, and `529` can retry the same model.

**Uncertain failures** such as timeouts, `408`, `500`, `502`, `503`, and `504` move to the next fallback because the original request may already have reached the provider.

A context-length rejection is terminal because sending the same oversized context to another attempt does not fix the input.

Waiting for a provider incident is not a useful test plan. The sample creates a controlled race instead.

`inference.py` sends a streaming request to `Kimi-K3` with a one-second first-event deadline:

```
result = chat_completion(
    token_key,
    "Kimi-K3",
    build_forced_failure_prompt(),
    extra_headers={
        "x-ltg-request-timeout": "1000",
        "x-ltg-max-attempts": "1",
    },
    stream=True,
    max_tokens=64,
)
```

The generated input is intentionally heavy, approximately 22,000 tokens, and includes a unique nonce so a repeated run cannot rely on provider KV caching to shorten prefill. The goal is for the primary's first event to miss the one-second deadline while `GLM-5.3-Flash` completes as the fallback.

Provider latency changes over time, so this is not presented as an absolute guarantee. The script tries up to three times and reports honestly if the primary answers within the deadline or if both attempts time out.

The client reads two headers from the raw response:

```
served_model = raw.headers.get("x-ltg-served-model")
attempts = raw.headers.get("x-ltg-attempts")
```

A successful failover should show:

```
requested model:   Kimi-K3
served model:      GLM-5.3-Flash
x-ltg-attempts:    2 or more
```

The SDK response model is useful, but the gateway headers make the routing decision explicit on the live path.

The next step reads the gateway spend events for the issued token key:

```
events = client.llm_token_gateway.spend.retrieve_events(
    start_date=start_date,
    end_date=end_date,
    token_key_id=token_key_id,
    page_number=1,
    page_size=50,
)

for event in events.data:
    print(
        event.requested_model,
        "->",
        event.model,
        "attempts:",
        event.attempts,
        "status:",
        event.status,
        "cache_hit:",
        event.cache_hit,
    )
```

When `requested_model` and `model` differ, the ledger provides the durable counterpart to the response header. The sample uses a half-open UTC date range and snapshot pagination so the report remains reproducible as the event set changes.

`GET /usage/summary` adds totals and per-model rollups when the account has the `llm_token_gateway.usage.read` permission. A `403` from that endpoint is treated as a nonfatal warning because the spend-events ledger still contains the request-level proof.

The gateway supports request-level overlays for attempts, delay, backoff, and timeout:

```
extra_headers = {
    "x-ltg-max-attempts": "1",
    "x-ltg-retry-delay": "1000",
    "x-ltg-backoff": "exponential",
}
```

These controls adjust one request without rewriting the token-group policy.

The sample also updates `cache_ttl` to 60 seconds and sends the same prompt twice. Gateway caching is best effort, so the script does not invent a cache hit: it reports `x-ltg-attempts: 0` when the repeat is served from cache and otherwise directs you to the ledger's `cache_hit` field.

Every update and delete follows the mutation-safety pattern:

`If-Match`
`Idempotency-Key`
A stale ETag returns `412`, preventing one process from overwriting another process's newer configuration.

Clone the repository and install the pinned dependencies:

```
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/gateway-fallbacks

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

cp .env.example .env
```

Add your Telnyx account API key to `.env`:

```
TELNYX_API_KEY=your_telnyx_api_key_here
PRIMARY_MODEL=Kimi-K3
FALLBACK_MODEL=GLM-5.3-Flash
```

Run the local smoke tests before making network requests:

```
python -m pytest smoke_test.py -v
```

Then run the five-step demonstration:

```
# Creates the group and key. The key secret is printed once.
python provision.py

export GATEWAY_TOKEN_KEY="<paste the secret>"

python inference.py
python ledger.py
python policy.py
python teardown.py
```

The live demonstration incurs real inference spend. The sample configures a maximum budget of USD 10 over one day, but you should review the policy before running it in your account.

`provision.py` stores only the group and key IDs in `.gateway_state.json`; it never writes the token secret to disk. `teardown.py` revokes the token key, deletes the group, verifies that the revoked key receives HTTP `401`, and removes the local state file.

The useful pattern is not limited to these two models:

That turns model failover from an assumption into something operators can inspect during the request and audit afterward.
