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