mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
feat(llm): custom request headers for OpenAI-compatible endpoints via LLM_EXTRA_HEADERS
This commit is contained in:
@@ -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`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="LLM_EXTRA_HEADERS" type="string">
|
||||
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.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="LLM_TIMEOUT" default="300" type="integer">
|
||||
Request timeout in seconds for LLM calls.
|
||||
</ParamField>
|
||||
|
||||
@@ -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.
|
||||
|
||||
+40
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user