From ebb3a62a99526233411fb416500462fed3ecefa5 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 30 Jul 2026 01:02:45 +0000 Subject: [PATCH] feat(llm): custom request headers for OpenAI-compatible endpoints via LLM_EXTRA_HEADERS --- docs/advanced/configuration.mdx | 8 ++++ docs/llm-providers/local.mdx | 17 +++++++ strix/config/models.py | 41 +++++++++++++++- strix/config/settings.py | 4 ++ tests/test_llm_extra_headers.py | 83 +++++++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 tests/test_llm_extra_headers.py diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index fe911907..b446fb67 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -19,6 +19,14 @@ Configure Strix using environment variables or a config file. Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`. + + Extra HTTP headers sent on every LLM request, as a JSON object (e.g. + `{"X-Feature-Key":"value","X-Tenant":"acme"}`). Useful for OpenAI-compatible + gateways that require attribution or routing headers in addition to the bearer + token. The bearer token itself still comes from `LLM_API_KEY`. Applies to both + the LiteLLM and native OpenAI routing paths. + + Request timeout in seconds for LLM calls. diff --git a/docs/llm-providers/local.mdx b/docs/llm-providers/local.mdx index 8a899a5d..212dd1b7 100644 --- a/docs/llm-providers/local.mdx +++ b/docs/llm-providers/local.mdx @@ -54,3 +54,20 @@ If you use LM Studio, vLLM, or other runners: export STRIX_LLM="openai/local-model" export LLM_API_BASE="http://localhost:1234/v1" # Adjust port as needed ``` + +### Gateways that require custom headers + +Some OpenAI-compatible gateways require extra HTTP headers (for attribution or +tenant routing) alongside the bearer token. Set them with `LLM_EXTRA_HEADERS` as +a JSON object — they are sent on every request: + +```bash +export STRIX_LLM="openai/your-model" +export LLM_API_BASE="https://your-gateway.example/v1" +export LLM_API_KEY="your-bearer-token" # sent as Authorization: Bearer ... +export LLM_EXTRA_HEADERS='{"X-Feature-Key":"value","X-Tenant":"acme"}' +``` + +For endpoints behind a private CA, point Strix at your certificate bundle with +the standard `SSL_CERT_FILE=/path/to/ca-bundle.pem` — never disable TLS +verification against a real endpoint. diff --git a/strix/config/models.py b/strix/config/models.py index 1401dc5a..34e298ca 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -33,7 +33,7 @@ if TYPE_CHECKING: from agents.models.interface import Model, ModelProvider from openai import AsyncOpenAI - from strix.config.settings import ReasoningEffort, Settings + from strix.config.settings import LlmSettings, ReasoningEffort, Settings def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None: @@ -243,6 +243,7 @@ def configure_sdk_model_defaults(settings: Settings) -> None: set_default_openai_api("chat_completions") else: set_default_openai_api("responses") + _configure_extra_headers(llm) def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None: @@ -347,6 +348,44 @@ def _configure_openrouter_attribution(model_name: str | None) -> None: litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment] +def _configure_extra_headers(llm: LlmSettings) -> None: + """Send user-provided default headers on every LLM request. + + Some OpenAI-compatible endpoints require extra HTTP headers (e.g. request + attribution or tenant routing) alongside the bearer token. Users supply + them via ``LLM_EXTRA_HEADERS``; they are applied to both routing paths: + the LiteLLM route (``litellm.headers``) and the SDK-native OpenAI route + (a default client carrying ``default_headers``), so they take effect + regardless of the ``STRIX_LLM`` prefix. + """ + headers = llm.extra_headers + if not headers: + return + _merge_litellm_headers(headers) + if llm.api_base: + _register_openai_client_with_headers(llm, headers) + + +def _merge_litellm_headers(headers: dict[str, str]) -> None: + import litellm + + current: object = litellm.headers + existing: dict[str, str] = current if isinstance(current, dict) else {} + litellm.headers = {**existing, **headers} # type: ignore[assignment] + + +def _register_openai_client_with_headers(llm: LlmSettings, headers: dict[str, str]) -> None: + from agents import set_default_openai_client + from openai import AsyncOpenAI + + client = AsyncOpenAI( + api_key=llm.api_key or "not-needed", + base_url=llm.api_base, + default_headers=dict(headers), + ) + set_default_openai_client(client, use_for_tracing=False) + + def _register_litellm_cost_callback() -> None: import litellm diff --git a/strix/config/settings.py b/strix/config/settings.py index 78d52273..31f479b0 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -35,6 +35,10 @@ class LlmSettings(BaseSettings): "OLLAMA_API_BASE", ), ) + extra_headers: dict[str, str] | None = Field( + default=None, + alias="LLM_EXTRA_HEADERS", + ) reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT") force_required_tool_choice: bool = Field( default=False, diff --git a/tests/test_llm_extra_headers.py b/tests/test_llm_extra_headers.py new file mode 100644 index 00000000..e65b72aa --- /dev/null +++ b/tests/test_llm_extra_headers.py @@ -0,0 +1,83 @@ +"""Tests for LLM_EXTRA_HEADERS: custom default headers on OpenAI-compatible endpoints.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import litellm +import pytest +from agents.models import _openai_shared + +from strix.config import loader +from strix.config.loader import load_settings +from strix.config.models import configure_sdk_model_defaults + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +_ENV_KEYS = ["STRIX_LLM", "LLM_API_KEY", "LLM_API_BASE", "LLM_EXTRA_HEADERS"] + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + for key in _ENV_KEYS: + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(loader, "_cached", None) + monkeypatch.setattr(loader, "_override", None) + + saved_headers = litellm.headers + saved_client = _openai_shared.get_default_openai_client() + litellm.headers = None + try: + yield + finally: + litellm.headers = saved_headers + _openai_shared.set_default_openai_client(saved_client) # type: ignore[arg-type] + + +def test_extra_headers_parsed_from_json_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-A": "1", "X-B": "2"})) + settings = load_settings() + assert settings.llm.extra_headers == {"X-A": "1", "X-B": "2"} + + +def test_extra_headers_merged_into_litellm_headers(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_LLM", "litellm/openai/some-model") + monkeypatch.setenv("LLM_API_BASE", "https://gateway.example/v1") + monkeypatch.setenv("LLM_API_KEY", "token") + headers = {"X-Feature-Key": "svc", "X-Tenant": "acme"} + monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps(headers)) + + configure_sdk_model_defaults(load_settings()) + + current: object = litellm.headers + assert isinstance(current, dict) + assert current["X-Feature-Key"] == "svc" + assert current["X-Tenant"] == "acme" + + +def test_extra_headers_applied_to_native_openai_client(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_LLM", "openai/some-model") + monkeypatch.setenv("LLM_API_BASE", "https://gateway.example/v1") + monkeypatch.setenv("LLM_API_KEY", "token") + monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Feature-Key": "svc"})) + + configure_sdk_model_defaults(load_settings()) + + client = _openai_shared.get_default_openai_client() + assert client is not None + assert client.default_headers.get("X-Feature-Key") == "svc" + assert str(client.base_url).rstrip("/") == "https://gateway.example/v1" + + +def test_no_extra_headers_leaves_litellm_headers_untouched(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_LLM", "openai/some-model") + monkeypatch.setenv("LLM_API_BASE", "https://gateway.example/v1") + monkeypatch.setenv("LLM_API_KEY", "token") + + configure_sdk_model_defaults(load_settings()) + + assert litellm.headers is None