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/loader.py b/strix/config/loader.py
index e6edd548..9edee5bc 100644
--- a/strix/config/loader.py
+++ b/strix/config/loader.py
@@ -6,7 +6,8 @@ import json
import logging
import os
from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from types import UnionType
+from typing import TYPE_CHECKING, Any, Union, get_args, get_origin
from pydantic import AliasChoices, BaseModel
@@ -126,13 +127,36 @@ def _read_json_overrides(path: Path) -> dict[str, dict[str, Any]]:
continue # env wins under some alias; skip the JSON file for this field
for alias in aliases:
if alias in env_block_upper:
- sub_data[fname] = env_block_upper[alias]
+ sub_data[fname] = _decode_saved_value(finfo, env_block_upper[alias])
break
if sub_data:
nested[sub_name] = sub_data
return nested
+def _decode_saved_value(finfo: FieldInfo, value: Any) -> Any:
+ """Decode a JSON string saved for a dict field, as pydantic does for env vars.
+
+ ``persist_current`` saves env values verbatim, so a dict setting such as
+ ``LLM_EXTRA_HEADERS`` lands in the file as JSON text. Values that are
+ already objects, or aren't valid JSON, are left for validation to judge.
+ """
+ if not isinstance(value, str) or not _is_dict_field(finfo):
+ return value
+ try:
+ return json.loads(value)
+ except ValueError:
+ return value
+
+
+def _is_dict_field(finfo: FieldInfo) -> bool:
+ annotation = finfo.annotation
+ candidates = (
+ get_args(annotation) if get_origin(annotation) in (Union, UnionType) else (annotation,)
+ )
+ return any(c is dict or get_origin(c) is dict for c in candidates)
+
+
def _first_alias_value(aliases: list[str], source: Mapping[str, Any]) -> Any | None:
return next((source[alias] for alias in aliases if alias in source), None)
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 5c5182a6..7c9c1d98 100644
--- a/strix/core/runner.py
+++ b/strix/core/runner.py
@@ -401,6 +401,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_config_loader.py b/tests/test_config_loader.py
index d083236a..463853d2 100644
--- a/tests/test_config_loader.py
+++ b/tests/test_config_loader.py
@@ -37,6 +37,12 @@ _LLM_ENV_KEYS = [
"STRIX_RUNTIME_BACKEND",
# TelemetrySettings
"STRIX_TELEMETRY",
+ # JSON-object settings
+ "LLM_EXTRA_HEADERS",
+ "LLM_EXTRA_BODY",
+ "STRIX_DEDUPE_MODEL",
+ "DEDUPE_LLM_EXTRA_HEADERS",
+ "DEDUPE_LLM_EXTRA_BODY",
]
@@ -406,6 +412,57 @@ def test_persist_current_replaces_corrupt_file(
assert json.loads(target.read_text(encoding="utf-8")) == {"env": {"STRIX_LLM": "env-model"}}
+@pytest.mark.parametrize(
+ ("env_var", "section", "field"),
+ [
+ ("LLM_EXTRA_HEADERS", "llm", "extra_headers"),
+ ("LLM_EXTRA_BODY", "llm", "extra_body"),
+ ("DEDUPE_LLM_EXTRA_HEADERS", "dedupe", "extra_headers"),
+ ("DEDUPE_LLM_EXTRA_BODY", "dedupe", "extra_body"),
+ ],
+)
+def test_persisted_json_object_setting_reloads_without_env(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, env_var: str, section: str, field: str
+) -> None:
+ loader.apply_config_override(tmp_path / "cli-config.json")
+ monkeypatch.setenv(env_var, '{"X-Tenant": "acme"}')
+ loader.load_settings()
+ loader.persist_current()
+ monkeypatch.delenv(env_var)
+ loader.apply_config_override(tmp_path / "cli-config.json")
+
+ settings = loader.load_settings()
+
+ assert getattr(getattr(settings, section), field) == {"X-Tenant": "acme"}
+
+
+def test_json_object_setting_saved_as_object_still_loads(tmp_path: Path) -> None:
+ target = tmp_path / "cli-config.json"
+ target.write_text(
+ json.dumps({"env": {"LLM_EXTRA_BODY": {"service_tier": "flex"}}}), encoding="utf-8"
+ )
+ loader.apply_config_override(target)
+
+ assert loader.load_settings().llm.extra_body == {"service_tier": "flex"}
+
+
+def test_invalid_saved_json_object_setting_is_rejected(tmp_path: Path) -> None:
+ target = tmp_path / "cli-config.json"
+ target.write_text(json.dumps({"env": {"LLM_EXTRA_BODY": "not json"}}), encoding="utf-8")
+ loader.apply_config_override(target)
+
+ with pytest.raises(ValidationError):
+ loader.load_settings()
+
+
+def test_saved_string_setting_is_not_json_decoded(tmp_path: Path) -> None:
+ target = tmp_path / "cli-config.json"
+ target.write_text(json.dumps({"env": {"STRIX_LLM": '{"not": "decoded"}'}}), encoding="utf-8")
+ loader.apply_config_override(target)
+
+ assert loader.load_settings().llm.model == '{"not": "decoded"}'
+
+
def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "cli-config.json"
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 62d6fab1..74f73c36 100644
--- a/tests/test_runner_mcp.py
+++ b/tests/test_runner_mcp.py
@@ -44,6 +44,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 a564a3ea..8c2e2047 100644
--- a/tests/test_runner_root_prompt.py
+++ b/tests/test_runner_root_prompt.py
@@ -64,6 +64,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),
)