Skip to main content

fastapi-ai-router

Turn your existing FastAPI routes into a natural-language-callable surface — in one line.

Drop-in middleware. Zero new metadata. Uses the OpenAPI schema FastAPI already generates.

Route with any LLM through LiteLLM, or skip the LLM and let TypeSafe's Jev classifier pick the route and its arguments in about 150 ms.

PyPI License: MIT Python 3.11+ FastAPI Pydantic v2 Code style: ruff Typed: mypy strict Tests: passing Coverage: gated Backends: LiteLLM | Jev Status: alpha


What it does

from fastapi import FastAPI
from fastapi_ai_router import AIRouter, ai_route
from fastapi_ai_router.backends.litellm import LiteLLMBackend

app = FastAPI()


@app.post("/orders/{order_id}/cancel")
@ai_route(description="Cancel a customer's order.")
def cancel_order(order_id: int, reason: str | None = None): ...


AIRouter(app, llm=LiteLLMBackend(model="gpt-4o-mini"))  # one line to enable
$ curl -X POST localhost:8000/ai \
    -H 'content-type: application/json' \
    -d '{"query":"cancel order 123 because it was a duplicate"}'
{
  "endpoint": "POST /orders/{order_id}/cancel",
  "args": {"order_id": 123, "reason": "duplicate"},
  "result": {"status": "cancelled"},
  "reasoning": "User wants to cancel order 123 with reason 'duplicate'.",
  "result_status": 200
}

That's it. The LLM picked the right route, filled the args, the middleware dispatched the call, and your existing Depends(auth) + middleware + Pydantic validation all ran normally.

The same request also works with no LLM at all: JevBackend().


Why this exists

Most LLM "routing" libraries are SaaS gateways or LangChain agents. There was no clean way to add a natural-language layer to an existing FastAPI app — until now. fastapi-ai-router is the conversational layer for any FastAPI codebase, and it leans on the OpenAPI schema FastAPI already generates so there's nothing new to maintain.

Need Without this library With this library
Add NL to one endpoint Write a LangChain agent + tool wrappers Add @ai_route
Add NL to a whole app Hand-code 50 tool wrappers AIRouter(app, llm=...)
Keep auth/middleware/validation Re-implement in your agent Free — loopback through FastAPI
Swap models or providers Rewrite agent Swap LLMBackend
Route high volume cheaply Pay for an LLM call per request JevBackend: one classifier call, no LLM
Test without an API key 🥲 FakeLLMBackend(returns=ToolCall(...))

How it works

AIRouter(app, llm=...) adds a single POST /ai endpoint to your FastAPI app. On the first request, it walks app.routes and projects each one (filtered by mode) into a JSON Schema tool definition — using the OpenAPI machinery FastAPI already generates. The user's natural-language {"query": "..."} is sent to your LLM along with those tool definitions; the LLM picks one tool and fills its arguments. The middleware then dispatches that call internally via httpx + ASGITransport (the same pattern FastAPI's TestClient uses), so your existing Depends(auth), middleware, validation, and exception handlers all run normally — auth and tracing headers are forwarded transparently. The dispatched response is wrapped in an envelope showing what the LLM picked and why, and returned to the client with the dispatched call's HTTP status code.

In the quickstart above, the LLM read the cancel_order route's description and signature, decided it was the right match for "cancel order 123, it was a duplicate", extracted order_id=123 and reason="duplicate" from the natural-language query, and the middleware dispatched the call exactly as if a normal client had hit POST /orders/123/cancel?reason=duplicate directly.

sequenceDiagram
    autonumber
    participant Client
    participant Router as AIRouter at /ai
    participant LLM
    participant Route as FastAPI route
    participant Deps as Depends(auth)

    Client->>Router: POST /ai with query JSON
    Note over Router: Layer-1 deps fire here
    Router->>Router: Build tool defs from app.routes (cached)
    Router->>LLM: messages + tools (OpenAI tool-calling shape)
    LLM-->>Router: ToolCall name and args
    Router->>Router: Resolve name to RouteSpec, un-flatten, URL-encode
    Router->>Deps: Forward Authorization via httpx ASGI loopback
    Deps->>Route: Layer-2 auth passes
    Route-->>Router: dispatched response
    Router->>Router: wrap_envelope(decision, response)
    Router-->>Client: 200 OK with envelope

Internally the architecture is small and split by responsibility:

flowchart LR
    subgraph public ["Public surface"]
        AIRouter(["AIRouter"])
        ai_route(["ai_route decorator"])
        LLMBackend(["LLMBackend Protocol"])
    end

    subgraph core ["Core pipeline"]
        introspection["introspection<br/>mode-aware route walk"]
        schema["schema<br/>OpenAPI to flat tool defs"]
        dispatcher["dispatcher<br/>un-flatten + ASGI loopback"]
        envelope["envelope<br/>wrap or raw"]
        observability["observability<br/>async hooks"]
    end

    subgraph backends ["Backends"]
        LiteLLM["LiteLLMBackend<br/>via litellm extra"]
        Jev["JevBackend<br/>via jev extra"]
        Fake["FakeLLMBackend<br/>for tests"]
        BYO["Your backend<br/>implements Protocol"]
    end

    AIRouter --> introspection
    AIRouter --> schema
    AIRouter --> dispatcher
    AIRouter --> envelope
    AIRouter --> observability
    AIRouter -. uses .-> LLMBackend
    LLMBackend -. implemented by .-> LiteLLM
    LLMBackend -. implemented by .-> Jev
    LLMBackend -. implemented by .-> Fake
    LLMBackend -. implemented by .-> BYO

Each module has one responsibility, ~100-300 lines, fully typed, fully tested.


Install

pip install fastapi-ai-router[litellm]       # any LLM, via LiteLLM
pip install fastapi-ai-router[jev]           # TypeSafe's Jev classifier (see below)
pip install "fastapi-ai-router[jev,litellm]" # Jev with an LLM fallback

The [litellm] extra gives you OpenAI / Anthropic / Gemini / Ollama / 100+ providers via LiteLLM — usually all you need. To bring your own LLM, implement the LLMBackend Protocol and skip the extra entirely:

pip install fastapi-ai-router

Choosing a backend

LiteLLMBackend JevBackend JevBackend(fallback=LiteLLMBackend(...))
How it picks LLM tool calling A classifier picks the route, then picks each argument from values in the query Jev first; the LLM only for what Jev can't do
Speed One LLM round trip 140–470 ms per request in a live run Jev's speed whenever Jev handles it
Cost per request LLM tokens About $0.00006 Jev, plus LLM tokens on fallback only
Arguments Anything the schema allows Integers, numbers, booleans, enums, words from the query Both
Confidence score No Yes, calibrated, per route Yes when Jev routes
Key Your provider's TYPESAFE_API_KEY Both

Pick LiteLLM when requests need free-form or structured arguments. Pick Jev when the route and its arguments are sitting in the user's words and latency or cost matters. The fallback combination answers with Jev when it can and calls the LLM only for the rest.


Exposure modes — explicit and safe by default

flowchart TD
    Start{Pick a mode<br/>at construction time}
    Start -->|"default — safest"| Decorator
    Start --> Tag
    Start --> All

    Decorator["mode='decorator'<br/><br/>Only routes decorated with<br/>@ai_route(expose=True)<br/>are exposed"]
    Tag["mode='tag', tag='ai'<br/><br/>Only routes whose tags=<br/>list contains the tag<br/>are exposed"]
    All["mode='all', exclude=[…]<br/><br/>Every route except<br/>excluded paths and<br/>@ai_route(expose=False)<br/>kill switches"]

    style Decorator fill:#d4edda,stroke:#28a745,color:#000
    style Tag fill:#fff3cd,stroke:#ffc107,color:#000
    style All fill:#f8d7da,stroke:#dc3545,color:#000

There is no silent fallback between modes — you pick one explicitly. expose=False on @ai_route is a kill switch that excludes a route from exposure in every mode, so you can mark sensitive routes as never-AI-callable regardless of how the AIRouter is configured elsewhere.

Mode Use when… Default safety
"decorator" You want surgical control over what's AI-callable. ✅ Safest. Empty surface until you opt in.
"tag" You already use FastAPI tags to organize routes. 🟡 Safe if your tagging is intentional.
"all" You're in a sandbox or trust the LLM completely. 🔴 Footgun. Pair with exclude= and expose=False.

Two-layer auth — auth doesn't reinvent itself

flowchart LR
    Client((Client)) -- "Authorization: Bearer …" --> L1
    L1{Layer 1<br/>dependencies= on /ai}
    L1 -- pass --> LLM[/LLM picks a tool/]
    L1 -- fail 401/403 --> Reject((Rejected — no LLM call))
    LLM --> Dispatch[/Dispatcher: forward Authorization/]
    Dispatch --> L2{Layer 2<br/>route's own Depends auth}
    L2 -- pass --> Handler[/Route handler runs/]
    L2 -- fail 401/403 --> Envelope[envelope.result_status<br/>= 401 or 403]
    Handler --> Envelope
    Envelope --> Client
  • Layer 1 gates "who can use the AI feature at all" (e.g., paid-tier check on /ai).
  • Layer 2 gates "who can call this specific endpoint" — and it's enforced by FastAPI's own Depends() chain on each dispatched route. Nothing about your auth changes. The Authorization header (and other configured headers) is forwarded transparently via the httpx loopback.

Bring your own LLM

from fastapi_ai_router import AIRouter, LLMBackend, Message, ToolCall, ToolDef


class MyBackend:
    async def call(self, messages: list[Message], tools: list[ToolDef]) -> ToolCall | None:
        # call your LLM, parse the response, return ToolCall(...) or None
        ...


AIRouter(app, llm=MyBackend())

No subclassing required — the LLMBackend is a structural Protocol. Test backends are built the same way:

from fastapi_ai_router.backends.fake import FakeLLMBackend

router = AIRouter(app, llm=FakeLLMBackend(returns=ToolCall(name="cancel", args={"order_id": 7}, ...)))

The whole test suite uses FakeLLMBackend and a stubbed Jev client, so it passes deterministically without a single API key.


Jev backend: routing without an LLM

Jev is TypeSafe's classifier. It doesn't generate text; it picks among options you give it and reports a calibrated confidence. JevBackend routes with that. The route is a choice over your tools, and each argument is a choice over values cut from the query (numbers, word spans, enum values). One request covers the route and every argument, typically in 120–350 ms and for a few thousandths of a cent.

from fastapi_ai_router.backends.jev import JevBackend

AIRouter(app, llm=JevBackend())  # reads TYPESAFE_API_KEY

A request from a live run against examples/05_jev.py, answered in 158 ms:

$ curl -X POST localhost:8000/ai -H 'content-type: application/json' \
    -d '{"query":"please cancel #4521, wrong size"}'
{
  "endpoint": "POST /orders/{order_id}/cancel",
  "args": {"order_id": 4521, "reason": "wrong size"},
  "result": {"status": "cancelled", "order_id": 4521, "reason": "wrong size"},
  "reasoning": null,
  "result_status": 200
}

reasoning is null because Jev picks from options instead of writing text. A query that fits no route, such as "what's the weather in Paris", returns 422 no_route_matched. A missing required value returns a 422 instead of a made-up one: "cancel my order" has no order number, so the response is 422 missing_path_param.

Jev handles integers, numbers, booleans, enums, and strings that appear word for word in the query. It can't build lists or nested objects, and it doesn't read number words ("five"). For those cases, and whenever it isn't sure which route fits, you can hand the request to an LLM:

from fastapi_ai_router.backends.litellm import LiteLLMBackend

AIRouter(app, llm=JevBackend(fallback=LiteLLMBackend(model="gpt-4o-mini")))
Jev's answer No fallback With fallback=
Confident that no route fits 422 no_route_matched same; the LLM is not called
Route confidence below min_confidence (default 0.5) 422 no_route_matched the LLM sees every tool
Route chosen, a required argument missing 422 naming the missing field the LLM sees only that route's tool

Limits:

  • Long queries are cut to the first 254 candidate spans per parameter.
  • String values are taken from the query's own words, punctuation stripped. An email address or similar won't survive intact, so route params like that through an LLM fallback.
  • Jev reads text literally and isn't hardened against adversarial input; keep route-level auth regardless of backend.
  • The SDK's default timeout is 10 s with retries. Tune it by passing your own client: JevBackend(client=AsyncTypeSafeClient(timeout=..., retry=...)).

Your on_decision hook receives Jev's route confidence as Decision.confidence.


Observability — pluggable, no vendor deps

from fastapi_ai_router import AIRouter, Decision, ErrorEvent


async def to_langfuse(d: Decision) -> None:
    await langfuse_client.log(...)


async def to_sentry(e: ErrorEvent) -> None:
    sentry_sdk.capture_message(...)


AIRouter(app, llm=..., on_decision=to_langfuse, on_error=to_sentry)

Every routing decision (and every error) flows through async hooks you control. Pipe to Langfuse, OpenTelemetry, Sentry, plain logs, or a Postgres table — the library has zero hard dependency on any tracing vendor.


Error semantics

Failure HTTP status Body shape
NoRouteMatched (LLM declined all tools) 422 {"error":"no_route_matched", "available_tools":[…]}
UnknownTool (LLM hallucinated a name) 422 {"error":"unknown_tool", "tool_name":"…"}
MissingPathParams (backend left out a path argument) 422 {"error":"missing_path_param", "missing":[…], "endpoint":"…"}
LLMBackendError (timeout, rate limit, etc.) 502 {"error":"llm_backend_error", "retryable":true}
Dispatched route 4xx/5xx passthrough envelope wraps the response, result_status set
DispatchError (transport failure) 500 {"error":"dispatch_error", "detail":"…"}

Dispatched-route errors are never swallowed. If the route's Depends(auth) rejects with 403, the /ai response is also 403 — the library does not silently flatten downstream errors to 200.


Configuration reference

AIRouter

Option Default What it does
llm required The backend that picks the route: LiteLLMBackend, JevBackend, FakeLLMBackend, or your own LLMBackend
mode "decorator" Which routes are exposed; see Exposure modes
tag "ai" The route tag that mode="tag" looks for
exclude None Path globs (fnmatch style, such as "/admin/*") that mode="all" skips
endpoint "/ai" Where the natural-language endpoint is mounted
dependencies None FastAPI dependencies on that endpoint, for Layer-1 auth or rate limits
raw_query_param "raw" POST /ai?raw=true (or 1, yes, on) returns the route's own response without the envelope
forward_headers authorization, cookie, x-api-key, x-forwarded-for, x-request-id Request headers passed through to the dispatched route
system_prompt A short routing instruction Sent to LLM backends; JevBackend doesn't use it
on_decision, on_error None Async hooks; see Observability

The route list is built on the first request and cached. If you add routes at runtime, call router.rebuild().

Response envelope

A request that reaches a route gets that route's HTTP status and this body:

Field Contents
endpoint The route that ran, such as "POST /orders/{order_id}/cancel"
args The arguments the backend chose
result The route's response body: JSON when the route returned JSON, text otherwise
reasoning The LLM's explanation when it gave one; null for Jev
result_status The route's HTTP status

JevBackend

Option Default What it does
model "jev-latest" A Jev alias, or a pinned version such as "jev-1.13.0"
api_key None Your TypeSafe key; when omitted, the SDK reads TYPESAFE_API_KEY. It never appears in repr()
min_confidence 0.5 Below this route confidence, Jev counts as unsure
fallback None The backend for unsure routes and for arguments Jev can't fill
max_span_words 6 The longest run of words offered as a string value
client None Your own AsyncTypeSafeClient, for timeouts, retries, or a different base URL

LiteLLMBackend

LiteLLMBackend(model, extra_kwargs={}). model is any LiteLLM model name. extra_kwargs go straight to litellm.acompletion, for settings such as temperature or api_base.


Not included yet, by design

Feature Why not yet
Multi-step / agent loops Stays out of LangChain's territory; sharp positioning
Conversation history Single-shot is the demo
Semantic caching Out of scope for the first wedge
Streaming SSE responses Adds complexity to the response path
Mountable sub-app Single dedicated endpoint is cleaner
Form / multipart bodies JSON-only keeps the loopback contract simple
Semantic prefiltering for 100+ routes Every exposed route is sent on every call today

Saying "we don't do this yet" up front is itself a positioning choice — see docs/concepts.md for the rationale.


Project status

Alpha. The latest release is v0.2.1. v0.2.0 added JevBackend. CHANGELOG.md lists every release and records every breaking change.

  • ✅ Core: introspection + dispatch + envelope + errors + observability
  • ✅ Three exposure modes (decorator / tag / all)
  • ✅ Backends shipped: LiteLLMBackend, JevBackend, and FakeLLMBackend for tests
  • ✅ Test suite passing under an 80% coverage gate, mypy strict, ruff clean
  • ✅ Examples + concepts/recipes/security docs

Documentation

Doc What it covers
docs/concepts.md Mental model, request flow, two-layer auth, mode comparison, caching
docs/recipes.md Custom backend, custom forwarding, tracing integrations, large-app strategies
docs/security.md When mode="all" is dangerous, prompt injection, header forwarding
examples/ Five runnable apps: basic, tag-mode, with-auth, with-observability, Jev
CONTRIBUTING.md Dev setup, testing without API keys, adding a backend

Testing

uv sync --extra dev
uv run pytest                                   # deterministic, no API keys
uv run pytest --cov=fastapi_ai_router           # coverage report
RUN_LLM_TESTS=1 uv run pytest tests/e2e/        # gated real-LLM smoke tests
RUN_LLM_TESTS=1 uv run --env-file .env pytest tests/e2e/test_with_real_jev.py   # real Jev calls

The test suite is deterministic and network-free by default. Every test uses FakeLLMBackend or a stubbed Jev client. Real-LLM and real-Jev tests are gated behind env vars and run only on release tags in CI.


Contributing

PRs welcome. See CONTRIBUTING.md for dev setup and the bar for new code (TDD, mypy strict, ruff clean, 80%+ coverage).

Particularly welcome:

  • New LLMBackend adapters (Anthropic-direct, Gemini-direct, vLLM, Ollama-direct, etc.)
  • Bug reports with minimal repro
  • Doc improvements

License

MIT — do whatever you like, attribution appreciated.

Built with care for the FastAPI community.

Metadata

Release files for fastapi-ai-router 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for fastapi-ai-router 0.2.1
File Size Uploaded Provenance
fastapi_ai_router-0.2.1.tar.gz 45.5 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for fastapi-ai-router 0.2.1
File Interpreter ABI Platform
fastapi_ai_router-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 75.9 kB

Release files / fastapi_ai_router-0.2.1.tar.gz

Download URL fastapi_ai_router-0.2.1.tar.gz
Size 45.5 kB
Tags Source
SHA-256 checksum
How to use checksums
6a2d896a5a7bc967b3b9c5c9dc239e47666cc598f675224054a27a4caa613133
BLAKE2b-256 checksum
How to use checksums
08cf3a6b78a87f5665a88d66d19ba4ed637cb676e3e5b74355ce9a0357a97b54
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.

Transparency log

Release files / fastapi_ai_router-0.2.1-py3-none-any.whl

Download URL fastapi_ai_router-0.2.1-py3-none-any.whl
Size 30.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3f955b32055639fa81a8b713898c3d0e11506c142a38863b621a4cd9b773789b
BLAKE2b-256 checksum
How to use checksums
23bdfe1bb6d464c98c86e3ddb235322242f0a91a92e57ad0f03f4ca9584befcc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.

Transparency log

Security for fastapi-ai-router 0.2.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for fastapi_ai_router-0.2.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.2.1 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page