diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index c245b364..29b003ec 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -33,6 +33,18 @@ Configure Strix using environment variables or a config file. the LiteLLM and native OpenAI routing paths. + + Extra fields merged into the JSON body of every request to the main model, as a JSON object + (e.g. `{"service_tier":"flex","safety_identifier":"9f86d081..."}`). Use it + for provider parameters Strix doesn't expose, such as OpenAI's `service_tier` + or `safety_identifier`. Applies to both the LiteLLM and native OpenAI routing + paths. Fields are not validated, so the provider or gateway must accept them; + unknown fields are usually rejected. Extra fields can override fields Strix + sends and break requests, so don't set request-shaping fields such as `model`, `messages`, `input`, + `tools` or `stream`, and use `STRIX_REASONING_EFFORT` rather than + `reasoning_effort`. + + Request timeout in seconds for LLM calls. @@ -88,6 +100,12 @@ affecting the agents that do the actual testing. inherits `LLM_EXTRA_HEADERS`; set this when its endpoint needs custom headers. + + Optional JSON object of extra request-body fields sent on every + deduplication-model request. A dedicated dedupe model never inherits + `LLM_EXTRA_BODY`; set this when its endpoint needs them too. + + Reasoning effort for the deduplication model. Defaults to the model's own baseline when unset. diff --git a/docs/llm-providers/local.mdx b/docs/llm-providers/local.mdx index 58b37509..cd2ca635 100644 --- a/docs/llm-providers/local.mdx +++ b/docs/llm-providers/local.mdx @@ -68,6 +68,21 @@ export LLM_API_KEY="your-bearer-token" # sent as Authorization: Bearer export LLM_EXTRA_HEADERS='{"X-Feature-Key":"value","X-Tenant":"acme"}' ``` +### Extra request-body fields + +Some provider parameters go in the request body rather than a header, and +Strix has no dedicated setting for them. Set them with `LLM_EXTRA_BODY` as a +JSON object — they are merged into the body of every request to the model. For example, to +use OpenAI's cheaper flex processing and attribute requests to a user with a +safety identifier (a hash, never a raw email): + +```bash +export LLM_EXTRA_BODY='{"service_tier":"flex","safety_identifier":"9f86d081884c7d65"}' +``` + +Fields aren't validated, so use only fields your provider accepts, and don't +set fields Strix already sends, such as `model`, `messages` or `stream`. + 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/settings.py b/strix/config/settings.py index 5aa6da06..b965281a 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal from pydantic import AliasChoices, Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -55,6 +55,11 @@ class LlmSettings(BaseSettings): alias="LLM_EXTRA_HEADERS", repr=False, ) + extra_body: dict[str, Any] | None = Field( + default=None, + alias="LLM_EXTRA_BODY", + repr=False, + ) reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT") force_required_tool_choice: bool = Field( default=False, @@ -110,6 +115,11 @@ class DedupeSettings(BaseSettings): alias="DEDUPE_LLM_EXTRA_HEADERS", repr=False, ) + extra_body: dict[str, Any] | None = Field( + default=None, + alias="DEDUPE_LLM_EXTRA_BODY", + repr=False, + ) @field_validator("reasoning_effort", mode="before") @classmethod diff --git a/strix/core/inputs.py b/strix/core/inputs.py index c36f71b6..7bb2b72c 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -250,6 +250,7 @@ def make_model_settings( request_timeout: float | None = None, prompt_cache: bool = True, extra_headers: dict[str, str] | None = None, + extra_body: dict[str, Any] | None = None, has_tools: bool = True, ) -> ModelSettings: headers = _request_headers(model_name, extra_headers) @@ -259,13 +260,16 @@ def make_model_settings( include_usage=True, extra_args=request_timeout_extra_args(request_timeout), extra_headers=headers, + extra_body=dict(extra_body) if extra_body else None, ) if ( reasoning_effort is not None and reasoning_effort != "none" and model_supports_reasoning(model_name) ): - model_settings = model_settings.resolve(_reasoning_settings(reasoning_effort)) + model_settings = model_settings.resolve( + _reasoning_settings(reasoning_effort, extra_body), + ) if force_required_tool_choice and _accepts_required_tool_choice(model_name): model_settings = model_settings.resolve(ModelSettings(tool_choice="required")) @@ -290,7 +294,9 @@ def _request_headers( return headers or None -def _reasoning_settings(effort: ReasoningEffort) -> ModelSettings: +def _reasoning_settings( + effort: ReasoningEffort, extra_body: dict[str, Any] | None = None +) -> ModelSettings: """``max`` is not in the OpenAI SDK's ``Reasoning.effort`` enum, so send it as a raw body field instead — also keeping it clear of LiteLLM's DeepSeek mapping, which collapses every ``reasoning_effort`` level to plain thinking-enabled. @@ -298,11 +304,13 @@ def _reasoning_settings(effort: ReasoningEffort) -> ModelSettings: It goes in ``extra_body``, the field every model implementation forwards as the request's ``extra_body``; the same value under ``extra_args`` collides with that - keyword and raises before a request is ever sent. + keyword and raises before a request is ever sent. ``resolve`` replaces + ``extra_body`` wholesale, so the user's ``LLM_EXTRA_BODY`` fields are carried + over, with ``reasoning_effort`` taking precedence. """ if effort != "max": return ModelSettings(reasoning=Reasoning(effort=effort)) - return ModelSettings(extra_body={"reasoning_effort": "max"}) + return ModelSettings(extra_body={**(extra_body or {}), "reasoning_effort": "max"}) def _prompt_cache_extra_args(model_name: str) -> dict[str, Any] | None: diff --git a/strix/core/runner.py b/strix/core/runner.py index 048a59de..0df4a8d8 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -381,6 +381,7 @@ async def run_strix_scan( request_timeout=settings.llm.timeout, prompt_cache=settings.llm.prompt_cache, extra_headers=settings.llm.extra_headers, + extra_body=settings.llm.extra_body, ) run_config = RunConfig( model=resolved_model, diff --git a/strix/interface/main.py b/strix/interface/main.py index cc3a0321..846a35da 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -164,12 +164,13 @@ async def warm_up_llm() -> None: raw_model = dedupe_model deduper = resolve_dedupe_model(settings.dedupe, dedupe_model) # A dedicated dedupe model may route to another provider, which must - # never receive the main endpoint's headers; it has its own - # DEDUPE_LLM_EXTRA_HEADERS. + # never receive the main endpoint's headers or body fields; it has + # its own DEDUPE_LLM_EXTRA_HEADERS and DEDUPE_LLM_EXTRA_BODY. await preflight_request( deduper, model_name=dedupe_model, extra_headers=settings.dedupe.extra_headers, + extra_body=settings.dedupe.extra_body, timeout=llm.preflight_timeout, api_base_setting="DEDUPE_LLM_API_BASE", ) diff --git a/strix/interface/scan_setup.py b/strix/interface/scan_setup.py index 0af126d0..62176f76 100644 --- a/strix/interface/scan_setup.py +++ b/strix/interface/scan_setup.py @@ -116,6 +116,7 @@ async def preflight_model_connection( model, model_name=model_name, extra_headers=resolved_settings.llm.extra_headers, + extra_body=resolved_settings.llm.extra_body, timeout=resolved_settings.llm.preflight_timeout, api_base_setting="LLM_API_BASE", ) @@ -126,6 +127,7 @@ async def preflight_request( *, model_name: str, extra_headers: dict[str, str] | None, + extra_body: dict[str, Any] | None, timeout: int, api_base_setting: str, ) -> None: @@ -144,6 +146,7 @@ async def preflight_request( request_timeout=timeout, prompt_cache=False, extra_headers=extra_headers, + extra_body=extra_body, has_tools=False, ) try: diff --git a/strix/llm/compaction.py b/strix/llm/compaction.py index ecdb9a83..6e40a433 100644 --- a/strix/llm/compaction.py +++ b/strix/llm/compaction.py @@ -307,6 +307,7 @@ async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None: request_timeout=llm.timeout, prompt_cache=False, extra_headers=llm.extra_headers, + extra_body=llm.extra_body, has_tools=False, ).resolve(ModelSettings(max_tokens=max_tokens)) try: diff --git a/strix/report/dedupe.py b/strix/report/dedupe.py index 93f9ad8a..b1d3e537 100644 --- a/strix/report/dedupe.py +++ b/strix/report/dedupe.py @@ -39,11 +39,13 @@ def _dedupe_model_settings( model_name=model_name, force_required_tool_choice=False, request_timeout=request_timeout, - # The main model's headers apply only when dedupe falls back to the main - # model; a dedicated dedupe model may route to another provider, which - # must never receive the main endpoint's credentials. A dedicated model - # gets its own DEDUPE_LLM_EXTRA_HEADERS instead. + # The main model's headers and body fields apply only when dedupe falls + # back to the main model; a dedicated dedupe model may route to another + # provider, which must never receive the main endpoint's credentials or + # identifiers. A dedicated model gets its own DEDUPE_LLM_EXTRA_HEADERS + # and DEDUPE_LLM_EXTRA_BODY instead. extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers, + extra_body=dedupe.extra_body if dedupe.model else llm.extra_body, has_tools=False, ) diff --git a/tests/test_compaction.py b/tests/test_compaction.py index 2d73d9ac..edf31edb 100644 --- a/tests/test_compaction.py +++ b/tests/test_compaction.py @@ -147,7 +147,9 @@ def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window: context.auto_compact = True settings = SimpleNamespace( context=context, - llm=SimpleNamespace(api_key=None, api_base=None, timeout=1, extra_headers=None), + llm=SimpleNamespace( + api_key=None, api_base=None, timeout=1, extra_headers=None, extra_body=None + ), ) monkeypatch.setattr(compaction, "load_settings", lambda: settings) @@ -228,7 +230,11 @@ async def test_summarize_routes_through_provider_with_settings( "load_settings", lambda: SimpleNamespace( llm=SimpleNamespace( - api_key=None, api_base=None, timeout=1, extra_headers={"X-Feature-Key": "svc"} + api_key=None, + api_base=None, + timeout=1, + extra_headers={"X-Feature-Key": "svc"}, + extra_body={"service_tier": "flex"}, ) ), ) @@ -239,6 +245,7 @@ async def test_summarize_routes_through_provider_with_settings( assert captured["model"] == "litellm/openai/some-model" settings = captured["model_settings"] assert settings.extra_headers == {"X-Feature-Key": "svc"} + assert settings.extra_body == {"service_tier": "flex"} assert settings.max_tokens == 64 diff --git a/tests/test_dedupe_model.py b/tests/test_dedupe_model.py index 809867b1..ab01345a 100644 --- a/tests/test_dedupe_model.py +++ b/tests/test_dedupe_model.py @@ -148,3 +148,42 @@ def test_config_file_loads_dedupe_model( assert settings.dedupe.reasoning_effort == "minimal" # Main model stays independent of the dedupe override. assert settings.llm.model == "openai/root" + + +def test_dedicated_dedupe_model_uses_own_body_fields_not_main( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LLM_EXTRA_BODY", json.dumps({"safety_identifier": "main"})) + loader._cached = None + try: + dedupe = DedupeSettings( + STRIX_DEDUPE_MODEL="deepseek/cheap", + DEDUPE_LLM_EXTRA_BODY={"service_tier": "flex"}, + ) + settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300) + assert settings.extra_body == {"service_tier": "flex"} + finally: + loader._cached = None + + +def test_dedicated_dedupe_model_gets_no_main_body_fields_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LLM_EXTRA_BODY", json.dumps({"safety_identifier": "main"})) + loader._cached = None + try: + dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap") + settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300) + assert settings.extra_body is None + finally: + loader._cached = None + + +def test_fallback_dedupe_inherits_main_body_fields(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LLM_EXTRA_BODY", json.dumps({"safety_identifier": "main"})) + loader._cached = None + try: + settings = _dedupe_model_settings(DedupeSettings(), "openai/main-model", 300) + assert settings.extra_body == {"safety_identifier": "main"} + finally: + loader._cached = None diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 2bdde73a..f54d3717 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -361,6 +361,58 @@ def test_make_model_settings_extra_headers_survive_reasoning_resolve() -> None: assert settings.extra_headers == {"X-Feature-Key": "svc"} +def test_make_model_settings_sets_extra_body() -> None: + settings = make_model_settings( + "none", + model_name="openai/gpt-5", + extra_body={"service_tier": "flex", "safety_identifier": "user-hash"}, + ) + + assert settings.extra_body == {"service_tier": "flex", "safety_identifier": "user-hash"} + + +def test_make_model_settings_omits_extra_body_when_unset() -> None: + assert make_model_settings("none", model_name="gpt-4o").extra_body is None + + +def test_make_model_settings_extra_body_survives_reasoning_resolve() -> None: + settings = make_model_settings( + "high", + model_name="openai/o3", + extra_body={"service_tier": "flex"}, + ) + + assert settings.extra_body == {"service_tier": "flex"} + + +def test_make_model_settings_extra_body_merges_with_max_reasoning_field() -> None: + settings = make_model_settings( + "max", + model_name="deepseek/deepseek-v4-flash", + extra_body={"safety_identifier": "user-hash"}, + ) + + assert settings.extra_body == {"safety_identifier": "user-hash", "reasoning_effort": "max"} + + +def test_make_model_settings_max_reasoning_overrides_extra_body_reasoning_field() -> None: + settings = make_model_settings( + "max", + model_name="deepseek/deepseek-v4-flash", + extra_body={"reasoning_effort": "low"}, + ) + + assert settings.extra_body == {"reasoning_effort": "max"} + + +def test_make_model_settings_does_not_mutate_caller_extra_body() -> None: + extra_body = {"safety_identifier": "user-hash"} + + make_model_settings("max", model_name="deepseek/deepseek-v4-flash", extra_body=extra_body) + + assert extra_body == {"safety_identifier": "user-hash"} + + def test_make_model_settings_timeout_survives_reasoning_resolve() -> None: # Reasoning is resolved via ModelSettings.resolve(); the timeout in extra_args # must not be dropped when a reasoning override is merged in. diff --git a/tests/test_llm_extra_body.py b/tests/test_llm_extra_body.py new file mode 100644 index 00000000..d5cfeddc --- /dev/null +++ b/tests/test_llm_extra_body.py @@ -0,0 +1,97 @@ +"""Tests for LLM_EXTRA_BODY: extra fields merged into every LLM request body.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import httpx +import litellm +import openai +import pytest +from agents.extensions.models.litellm_model import LitellmModel +from agents.models.interface import ModelTracing +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from agents.models.openai_responses import OpenAIResponsesModel + +from strix.config import loader +from strix.config.loader import load_settings +from strix.core.inputs import make_model_settings + + +if TYPE_CHECKING: + from collections.abc import Iterator + + from agents.models.interface import Model + + +_EXTRA_BODY = {"service_tier": "flex", "safety_identifier": "user-hash"} + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.delenv("LLM_EXTRA_BODY", raising=False) + monkeypatch.setattr(loader, "_cached", None) + monkeypatch.setattr(loader, "_override", None) + yield + + +def test_extra_body_parsed_from_json_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LLM_EXTRA_BODY", json.dumps({"service_tier": "flex", "n": 1})) + + assert load_settings().llm.extra_body == {"service_tier": "flex", "n": 1} + + +async def _send(model: Model) -> None: + await model.get_response( + system_instructions=None, + input="Reply with just 'OK'.", + model_settings=make_model_settings(None, model_name="gpt-5", extra_body=_EXTRA_BODY), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ) + + +def _capturing_client(captured: dict[str, Any]) -> openai.AsyncOpenAI: + def handler(request: httpx.Request) -> httpx.Response: + captured.update(json.loads(request.content)) + return httpx.Response(400, json={"error": {"message": "captured"}}) + + return openai.AsyncOpenAI( + api_key="test", + base_url="https://gateway.example/v1", + max_retries=0, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_cls", [OpenAIChatCompletionsModel, OpenAIResponsesModel]) +async def test_extra_body_reaches_native_openai_request_body(model_cls: type) -> None: + captured: dict[str, Any] = {} + + with pytest.raises(openai.BadRequestError): + await _send(model_cls(model="gpt-5", openai_client=_capturing_client(captured))) + + assert {k: captured.get(k) for k in _EXTRA_BODY} == _EXTRA_BODY + + +@pytest.mark.asyncio +async def test_extra_body_passed_to_litellm(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + async def fake_acompletion(**kwargs: Any) -> Any: + captured.update(kwargs) + raise RuntimeError("captured") + + monkeypatch.setattr(litellm, "acompletion", fake_acompletion) + + with pytest.raises(RuntimeError, match="captured"): + await _send(LitellmModel(model="openai/gpt-5", base_url="https://gateway.example/v1")) + + assert captured["extra_body"] == _EXTRA_BODY diff --git a/tests/test_preflight_timeout.py b/tests/test_preflight_timeout.py index cf2192f8..6ca4cf77 100644 --- a/tests/test_preflight_timeout.py +++ b/tests/test_preflight_timeout.py @@ -60,6 +60,7 @@ def test_preflight_request_fails_after_its_own_timeout_with_a_clear_message() -> model, # type: ignore[arg-type] model_name="openai/gpt-4o", extra_headers=None, + extra_body=None, timeout=1, api_base_setting="DEDUPE_LLM_API_BASE", ) @@ -130,3 +131,52 @@ def test_warm_up_checks_the_dedupe_model_with_its_own_headers_and_the_preflight_ "DEDUPE_LLM_API_BASE", ] assert calls[1][0] is dedupe_model + + +def test_warm_up_checks_each_model_with_its_own_body_fields( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("STRIX_LLM", "openai/gpt-4o") + monkeypatch.setenv("LLM_API_KEY", "sk-test") + monkeypatch.setenv("LLM_EXTRA_BODY", '{"safety_identifier": "main"}') + monkeypatch.setenv("STRIX_DEDUPE_MODEL", "anthropic/claude-sonnet-4-5") + monkeypatch.setenv("DEDUPE_LLM_EXTRA_BODY", '{"service_tier": "flex"}') + _fresh_settings(monkeypatch, tmp_path) + + calls: list[dict[str, Any]] = [] + + async def record(_model: Any, **kwargs: Any) -> None: + calls.append(kwargs) + + monkeypatch.setattr(scan_setup, "preflight_request", record) + monkeypatch.setattr(cli_main, "preflight_request", record) + monkeypatch.setattr("strix.config.models.configure_sdk_model_defaults", lambda _settings: None) + monkeypatch.setattr("strix.report.dedupe.resolve_dedupe_model", lambda _dedupe, _name: object()) + + asyncio.run(cli_main.warm_up_llm()) + + assert [kwargs["extra_body"] for kwargs in calls] == [ + {"safety_identifier": "main"}, + {"service_tier": "flex"}, + ] + + +def test_preflight_request_sends_the_extra_body() -> None: + seen: list[Any] = [] + + class _Recorder: + async def get_response(self, *, model_settings: Any, **_: Any) -> None: + seen.append(model_settings.extra_body) + + asyncio.run( + preflight_request( + _Recorder(), # type: ignore[arg-type] + model_name="openai/gpt-4o", + extra_headers=None, + extra_body={"safety_identifier": "main"}, + timeout=5, + api_base_setting="LLM_API_BASE", + ) + ) + + assert seen == [{"safety_identifier": "main"}] diff --git a/tests/test_runner_interrupt.py b/tests/test_runner_interrupt.py index 054f5b7a..b6271407 100644 --- a/tests/test_runner_interrupt.py +++ b/tests/test_runner_interrupt.py @@ -28,6 +28,7 @@ def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None: timeout=300, prompt_cache=True, extra_headers=None, + extra_body=None, ), runtime=types.SimpleNamespace(max_context_images=3), ) diff --git a/tests/test_runner_mcp.py b/tests/test_runner_mcp.py index 25a5c888..cbcad7c7 100644 --- a/tests/test_runner_mcp.py +++ b/tests/test_runner_mcp.py @@ -32,6 +32,7 @@ def _settings() -> Any: timeout=300, prompt_cache=True, extra_headers=None, + extra_body=None, ), runtime=types.SimpleNamespace(max_context_images=3), ) diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py index 3110ae2c..d6bad9ae 100644 --- a/tests/test_runner_rate_limit.py +++ b/tests/test_runner_rate_limit.py @@ -42,6 +42,7 @@ async def test_persistent_rate_limit_stops_gracefully( timeout=300, prompt_cache=True, extra_headers=None, + extra_body=None, ), runtime=types.SimpleNamespace(max_context_images=3), ) diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py index e25d09ab..e4b4a58a 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -58,6 +58,7 @@ def _patch_engine_scaffold( timeout=300, prompt_cache=True, extra_headers=None, + extra_body=None, ), runtime=types.SimpleNamespace(max_context_images=3), ) diff --git a/tests/test_runner_teardown.py b/tests/test_runner_teardown.py index d7e9645d..e3c43696 100644 --- a/tests/test_runner_teardown.py +++ b/tests/test_runner_teardown.py @@ -52,6 +52,7 @@ def _settings() -> Any: timeout=300, prompt_cache=True, extra_headers=None, + extra_body=None, ), runtime=types.SimpleNamespace(max_context_images=3), )