Headroom

Pipeline Extensions

Write a request-normalization extension for a quirky upstream provider, and route requests to different upstream bases per request with x-headroom-base-url.

Headroom emits lifecycle events at every stage of the canonical request pipeline. Third-party packages can hook these events — without forking Headroom — by registering a pipeline extension under the headroom.pipeline_extension entry-point group. Extensions can mutate messages, tools, headers, or metadata in place before the request is forwarded upstream.

Both the SDK client and the proxy dispatch the same events, so one extension covers both deployments.

Lifecycle stages

Extensions receive a PipelineEvent for each stage in headroom.pipeline.PipelineStage:

StageWhen
SETUP, PRE_START, POST_STARTProcess/pipeline startup
INPUT_RECEIVEDRaw request accepted
INPUT_CACHED, INPUT_ROUTED, INPUT_COMPRESSED, INPUT_REMEMBEREDCache, routing, compression, memory stages
PRE_SENDLast hook before the request is forwarded upstream
POST_SEND, RESPONSE_RECEIVEDAfter forwarding / on response
OUTCOME_OBSERVEDEmits a read-only OutcomeSnapshot (tokens, stop reason, transforms applied) once the response is fully processed

PRE_SEND is the right stage for normalizing requests to fit a quirky upstream: compression and caching are done, and whatever you write into event.messages is exactly what the provider receives.

Recipe: normalize requests for a quirky upstream provider

Some OpenAI-compatible gateways reject valid OpenAI-spec payloads. A real example: an upstream returns 400 "Message content is null" for assistant messages that carry content: null alongside tool_calls — a combination the OpenAI spec explicitly produces when the model returns only tool calls. The provider-recommended workaround is to send content: "" instead.

An extension that rewrites those messages at PRE_SEND:

# my_headroom_ext/normalize.py
from headroom.pipeline import PipelineEvent, PipelineStage


class NullContentNormalizer:
    """Rewrite `content: null` + tool_calls to `content: ""` before send."""

    def on_pipeline_event(self, event: PipelineEvent) -> PipelineEvent | None:
        if event.stage is not PipelineStage.PRE_SEND or not event.messages:
            return None
        for message in event.messages:
            if (
                message.get("role") == "assistant"
                and message.get("content") is None
                and message.get("tool_calls")
            ):
                message["content"] = ""
        return None  # mutated in place; returning None keeps the event

Register it as an entry point in your extension package:

# pyproject.toml of your extension package
[project.entry-points."headroom.pipeline_extension"]
null-content-normalizer = "my_headroom_ext.normalize:NullContentNormalizer"

Install the package into the same environment as Headroom (pip install my-headroom-ext) and it is discovered automatically — entry points are loaded on startup, and a failing extension is isolated and logged rather than breaking the pipeline.

Notes on the contract:

  • An extension is either an object with an on_pipeline_event(event) method or a class Headroom instantiates with no arguments.
  • Return None (mutate in place) or return a replacement PipelineEvent.
  • Exceptions raised by an extension are caught and logged (fail-open); the request proceeds unmodified.
  • Discovery can be disabled with the SDK config flag discover_pipeline_extensions=False, and explicit instances can be passed via pipeline_extensions=[...] (SDK HeadroomConfig and proxy ProxyConfig both expose these fields).

Per-request upstream routing with x-headroom-base-url

To route different models through one Headroom instance to different OpenAI-compatible upstream bases — instead of one global OPENAI_API_URL / OPENAI_TARGET_API_URL per proxy process — send the x-headroom-base-url request header. The dedicated OpenAI handlers (/v1/chat/completions, /v1/responses) and the generic passthrough route all honor it, falling back to the configured upstream when absent:

curl http://localhost:8787/v1/chat/completions \
  -H "content-type: application/json" \
  -H "x-headroom-base-url: https://api.example-gateway.ai/gemini-3-flash" \
  -d '{"model": "gemini-3-flash", "messages": [{"role": "user", "content": "hi"}]}'

Internal x-headroom-* headers (including this one) are stripped before the request is forwarded upstream by default — see HEADROOM_STRIP_INTERNAL_HEADERS in Configuration.

Because this header is client-driven, operator-configured secret headers (OPENAI_TARGET_API_HEADERS / ANTHROPIC_TARGET_API_HEADERS) are only attached when the resolved upstream host is one you designated — a configured provider target, or a host in HEADROOM_UPSTREAM_ALLOWED_HOSTS. Other upstreams are still routed to, just without those headers. See Configuration for details.

Per-request model routing with request.state.headroom_route

x-headroom-base-url is client-driven and points at one OpenAI-compatible base. When the choice of model belongs to an extension instead of the caller — a router that picks a cheaper model per turn, say — publish it on the request state and Headroom serves that one request from a backend that speaks the target provider:

# middleware or an extension holding the request
request.state.headroom_route = SimpleNamespace(
    model="moonshot/kimi-k2",   # required
    provider="moonshot",        # optional; inferred from the model id if absent
    reason="cheaper at this prefix length",
)

The contract, in headroom/proxy/route_advice.py:

  • Absent means unchanged. No advice — or advice that is malformed, names an unknown provider, or fails to build a backend — and the request takes exactly the path it took before. A routing preference can never take traffic down.
  • Duck-typed, so an extension does not import Headroom to publish one.
  • A native provider (anthropic) needs no backend switch — rewrite body["model"] yourself. A foreign one is translated by a LiteLLMBackend built for it, and Headroom writes the model id.
  • Backends are built once per provider and cached; a provider that fails to build is not retried per request.
  • Honored on /v1/messages and /v1/chat/completions, streaming and non-streaming alike. (Not the Responses API, which does not use the backend abstraction.)

routemegood is the reference consumer of this seam: it decides, Headroom routes.

On this page