Compare commits

..
23 changed files with 67 additions and 1010 deletions
-14
View File
@@ -19,14 +19,6 @@ 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>
@@ -63,12 +55,6 @@ affecting the agents that do the actual testing.
model runs on a different endpoint than the main model.
</ParamField>
<ParamField path="DEDUPE_LLM_EXTRA_HEADERS" type="string">
Optional JSON object of extra HTTP headers sent on every deduplication-model
request, e.g. `{"X-Feature-Key":"value"}`. A dedicated dedupe model never
inherits `LLM_EXTRA_HEADERS`; set this when its endpoint needs custom headers.
</ParamField>
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
Reasoning effort for the deduplication model. Defaults to the model's own
baseline when unset.
-17
View File
@@ -54,20 +54,3 @@ 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.
-1
View File
@@ -220,7 +220,6 @@ ignore = [
# Stdlib HTTP handler overrides (do_GET/do_POST).
"strix/interface/auth_cli.py" = ["N802"]
"tests/test_codex_streaming.py" = ["N802"]
"tests/test_disable_streaming.py" = ["N802"]
"tests/test_report_pdf.py" = ["S105", "S106"]
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
+4 -179
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import contextlib
import inspect
import os
import time
from typing import TYPE_CHECKING, Any
from agents import (
@@ -14,8 +13,6 @@ from agents import (
set_tracing_disabled,
)
from agents.model_settings import ModelSettings
from agents.models.fake_id import FAKE_RESPONSES_ID
from agents.models.interface import Model
from agents.models.multi_provider import MultiProvider
from agents.models.openai_responses import OpenAIResponsesModel
from agents.retry import (
@@ -24,8 +21,6 @@ from agents.retry import (
RetryPolicyContext,
retry_policies,
)
from openai.types.responses import Response, ResponseCompletedEvent
from openai.types.responses.response_usage import ResponseUsage
from openai.types.shared import Reasoning
from strix.config import codex
@@ -35,17 +30,10 @@ from strix.config.loader import load_settings
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from agents.agent_output import AgentOutputSchemaBase
from agents.handoffs import Handoff
from agents.items import ModelResponse, TResponseInputItem, TResponseStreamEvent
from agents.models.interface import ModelProvider, ModelTracing
from agents.retry import ModelRetryAdvice, ModelRetryAdviceRequest
from agents.tool import Tool
from agents.usage import Usage
from agents.models.interface import Model, ModelProvider
from openai import AsyncOpenAI
from openai.types.responses.response_prompt_param import ResponsePromptParam
from strix.config.settings import LlmSettings, ReasoningEffort, Settings
from strix.config.settings import ReasoningEffort, Settings
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
@@ -147,124 +135,6 @@ class _CodexResponsesModel(OpenAIResponsesModel):
await result
class _NonStreamingModel(Model):
"""Serve the SDK's streamed run loop from a single non-streaming request.
Some OpenAI-compatible gateways do not support Server-Sent Events, or
deliver them unreliably (dropping structured tool-call deltas, or stalling
mid-stream so the whole turn waits out the read timeout). The SDK run loop
Strix uses only issues streamed requests, so such a gateway fails every
turn. Opt in with ``LLM_DISABLE_STREAMING=true`` to wrap the resolved model
so each turn makes one non-streaming ``get_response`` (``stream:false`` on
the wire) and the completed result is replayed as a single terminal stream
event. The run loop then executes tools and emits run items from that final
response exactly as it would for a real stream, so nothing else changes.
"""
def __init__(self, inner: Model) -> None:
self._inner = inner
async def close(self) -> None:
await self._inner.close()
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
return self._inner.get_retry_advice(request)
async def get_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem], # noqa: A002
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> ModelResponse:
return await self._inner.get_response(
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
)
async def stream_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem], # noqa: A002
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> AsyncIterator[TResponseStreamEvent]:
response = await self._inner.get_response(
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
)
yield _completed_stream_event(response, getattr(self._inner, "model", None))
def _completed_stream_event(
model_response: ModelResponse, model_name: object | None
) -> TResponseStreamEvent:
"""Wrap a non-streamed ``ModelResponse`` as the terminal event of a stream.
The run loop builds its authoritative per-turn response solely from the
``response.completed`` event, so a single event carrying the full output
and usage is all it needs.
"""
response = Response(
id=model_response.response_id or FAKE_RESPONSES_ID,
created_at=time.time(),
model=str(model_name) if model_name else "",
object="response",
output=list(model_response.output),
tool_choice="auto",
tools=[],
parallel_tool_calls=False,
usage=_response_usage(model_response.usage),
)
return ResponseCompletedEvent(
response=response,
sequence_number=0,
type="response.completed",
)
def _response_usage(usage: Usage | None) -> ResponseUsage | None:
if usage is None:
return None
return ResponseUsage(
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
total_tokens=usage.total_tokens,
input_tokens_details=usage.input_tokens_details,
output_tokens_details=usage.output_tokens_details,
)
class StrixProvider(MultiProvider):
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
so users type ``deepseek/deepseek-chat`` rather than
@@ -289,21 +159,14 @@ class StrixProvider(MultiProvider):
return self._get_fallback_provider("litellm"), original_model_name
def get_model(self, model_name: str | None) -> Model:
llm = load_settings().llm
slug = codex.subscription_model(model_name)
if slug:
# The ChatGPT subscription backend is always streamed; it has no
# non-streaming mode to fall back to, so LLM_DISABLE_STREAMING
# does not apply here.
return _CodexResponsesModel(
slug,
codex.get_subscription_client(),
reasoning_effort=llm.reasoning_effort,
reasoning_effort=load_settings().llm.reasoning_effort,
)
model = super().get_model(model_name)
if llm.disable_streaming:
return _NonStreamingModel(model)
return model
return super().get_model(model_name)
DEFAULT_MODEL_RETRY = ModelRetrySettings(
@@ -380,7 +243,6 @@ 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:
@@ -485,43 +347,6 @@ 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)
_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
-12
View File
@@ -35,10 +35,6 @@ 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,
@@ -48,10 +44,6 @@ class LlmSettings(BaseSettings):
default=True,
alias="STRIX_PROMPT_CACHE",
)
disable_streaming: bool = Field(
default=False,
alias="LLM_DISABLE_STREAMING",
)
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
@@ -65,10 +57,6 @@ class DedupeSettings(BaseSettings):
)
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
extra_headers: dict[str, str] | None = Field(
default=None,
alias="DEDUPE_LLM_EXTRA_HEADERS",
)
class ContextSettings(BaseSettings):
-24
View File
@@ -55,23 +55,6 @@ _INPUT_REJECTION_CODES = frozenset({400, 404, 422})
_MAX_COMPACTIONS_PER_CYCLE = 2
class ProviderRefusalError(AgentsException):
"""Raised when a provider returns a structured refusal instead of an exception."""
def _structured_provider_refusal(result: Any) -> str | None:
for item in getattr(result, "new_items", ()) or ():
raw_item = getattr(item, "raw_item", None)
for content in getattr(raw_item, "content", ()) or ():
if getattr(content, "type", None) != "refusal":
continue
refusal = getattr(content, "refusal", None)
if isinstance(refusal, str) and refusal.strip():
return refusal.strip()
return "The model provider refused this request."
return None
def _run_config_model(run_config: RunConfig) -> str | None:
return run_config.model if isinstance(run_config.model, str) else None
@@ -507,8 +490,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
logger.exception("stream event sink failed for %s", agent_id)
if stream.run_loop_exception is not None:
raise stream.run_loop_exception
if refusal := _structured_provider_refusal(stream):
raise ProviderRefusalError(refusal)
except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError):
raise
except RuntimeError as stream_exc:
@@ -603,11 +584,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
return await _handle_content_guardrail(
coordinator, agent_id, exc, interactive=interactive
)
if isinstance(exc, ProviderRefusalError):
logger.warning("agent %s refused by the model provider: %s", agent_id, exc)
await coordinator.set_status(agent_id, "failed", error=str(exc))
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
if not interactive:
raise
if isinstance(exc, MaxTurnsExceeded):
-2
View File
@@ -132,14 +132,12 @@ def make_model_settings(
force_required_tool_choice: bool = False,
request_timeout: float | None = None,
prompt_cache: bool = True,
extra_headers: dict[str, str] | None = None,
) -> ModelSettings:
model_settings = ModelSettings(
parallel_tool_calls=False,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(request_timeout),
extra_headers=dict(extra_headers) if extra_headers else None,
)
if (
reasoning_effort is not None
-1
View File
@@ -250,7 +250,6 @@ async def run_strix_scan(
force_required_tool_choice=settings.llm.force_required_tool_choice,
request_timeout=settings.llm.timeout,
prompt_cache=settings.llm.prompt_cache,
extra_headers=settings.llm.extra_headers,
)
run_config = RunConfig(
model=resolved_model,
+3 -21
View File
@@ -31,7 +31,7 @@ from strix.config.models import (
is_known_openai_bare_model,
is_recommended_or_frontier_model,
)
from strix.core.inputs import DEFAULT_MAX_TURNS, make_model_settings
from strix.core.inputs import DEFAULT_MAX_TURNS
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.interface.cli import run_cli
from strix.interface.tui import run_tui
@@ -382,13 +382,7 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
model.get_response(
system_instructions="You are a helpful assistant.",
input="Reply with just 'OK'.",
model_settings=make_model_settings(
None,
model_name=raw_model,
request_timeout=llm.timeout,
prompt_cache=False,
extra_headers=llm.extra_headers,
),
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
@@ -410,19 +404,7 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
# Match the runtime path: send the dedupe key/endpoint per call so a
# separate-provider dedupe model authenticates during warm-up too.
deduper_extra = _dedupe_extra_args(settings.dedupe)
# 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.
deduper_settings = make_model_settings(
None,
model_name=dedupe_model,
request_timeout=llm.timeout,
prompt_cache=False,
extra_headers=settings.dedupe.extra_headers,
)
if deduper_extra:
merged = {**(deduper_settings.extra_args or {}), **deduper_extra}
deduper_settings = deduper_settings.resolve(ModelSettings(extra_args=merged))
deduper_settings = ModelSettings(extra_args=deduper_extra or None)
await asyncio.wait_for(
deduper.get_response(
system_instructions="You are a helpful assistant.",
+1 -2
View File
@@ -15,7 +15,6 @@ from typing import TYPE_CHECKING, Any, ClassVar
if TYPE_CHECKING:
from pygments.token import _TokenType
from textual.timer import Timer
from rich.align import Align
@@ -353,7 +352,7 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
if not token_value:
continue
color = None
tt: _TokenType | None = token_type
tt = token_type
while tt:
if tt in colors:
color = colors[tt]
+12 -44
View File
@@ -12,20 +12,15 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
import litellm
from litellm.exceptions import BadRequestError, ContextWindowExceededError
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from strix.config import load_settings
from strix.config.models import StrixProvider
from strix.core.inputs import make_model_settings
from strix.core.sessions import replace_session_items, session_write_lock
from strix.llm.context_budget import context_window, count_tokens, output_limit
if TYPE_CHECKING:
from agents.items import ModelResponse
from agents.memory import Session
@@ -273,53 +268,26 @@ def _checkpoint_item(summary: str) -> dict[str, Any]:
}
def _extract_text(response: ModelResponse) -> str:
parts: list[str] = []
for item in response.output:
if not isinstance(item, ResponseOutputMessage):
continue
parts.extend(
chunk.text
for chunk in item.content
if isinstance(chunk, ResponseOutputText) and chunk.text
)
return "".join(parts)
async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
llm = load_settings().llm
model_settings = make_model_settings(
None,
model_name=model,
request_timeout=llm.timeout,
prompt_cache=False,
extra_headers=llm.extra_headers,
).resolve(ModelSettings(max_tokens=max_tokens))
try:
response = (
await StrixProvider()
.get_model(model)
.get_response(
system_instructions=None,
input=prompt,
model_settings=model_settings,
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
response = await litellm.acompletion(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
api_key=llm.api_key,
api_base=llm.api_base,
timeout=llm.timeout,
)
except Exception:
logger.exception("compaction summary call failed for model %s", model)
return None
content = _extract_text(response).strip()
if not content:
try:
content = response.choices[0].message.content
except (AttributeError, IndexError, KeyError):
logger.warning("compaction summary returned no content")
return None
return content
return content.strip() if isinstance(content, str) and content.strip() else None
async def maybe_compact(
+2 -13
View File
@@ -17,14 +17,7 @@ logger = logging.getLogger(__name__)
# LiteLLM keys models without the routing prefix users type (``openai/``,
# ``litellm/``, ``ollama/`` ...). Strip a leading provider segment on lookup.
_STRIPPABLE_PREFIXES = (
"openai/",
"chatgpt/",
"litellm/",
"any-llm/",
"ollama/",
"ollama_chat/",
)
_STRIPPABLE_PREFIXES = ("openai/", "litellm/", "any-llm/", "ollama/", "ollama_chat/")
_DEFAULT_OUTPUT_TOKENS = 8_192
@@ -45,11 +38,7 @@ def _safe_get_model_info(model: str) -> dict[str, Any] | None:
@lru_cache(maxsize=128)
def _model_info(model: str) -> dict[str, int]:
lookup_key = _lookup_key(model)
# Provider-qualified ChatGPT lookups may start a synchronous device-login
# poll. LiteLLM keys the metadata by the underlying model slug.
candidates = (lookup_key,) if model.startswith("chatgpt/") else (model, lookup_key)
for candidate in candidates:
for candidate in (model, _lookup_key(model)):
info = _safe_get_model_info(candidate)
if info is not None:
return {
+3 -8
View File
@@ -51,24 +51,17 @@ def _dedupe_extra_args(dedupe: DedupeSettings) -> dict[str, str]:
def _dedupe_model_settings(
dedupe: DedupeSettings, model_name: str, request_timeout: float | None
) -> ModelSettings:
llm = load_settings().llm
settings = make_model_settings(
dedupe.reasoning_effort,
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.
extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers,
)
extra = _dedupe_extra_args(dedupe)
if extra:
settings = settings.resolve(ModelSettings(extra_args=extra))
return settings
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
as any existing report.
@@ -354,7 +347,9 @@ async def check_duplicate(
response = await model.get_response(
system_instructions=DEDUPE_SYSTEM_PROMPT,
input=user_msg,
model_settings=_dedupe_model_settings(dedupe, resolved_model, settings.llm.timeout),
model_settings=_dedupe_model_settings(
dedupe, resolved_model, settings.llm.timeout
),
tools=[],
output_schema=None,
handoffs=[],
-14
View File
@@ -110,19 +110,6 @@ def _apply_log_limits(create_kwargs: dict[str, Any]) -> None:
)
def _apply_run_labels(create_kwargs: dict[str, Any]) -> None:
run_id = os.getenv("STRIX_RUN_ID")
if not run_id:
return
labels = create_kwargs.setdefault("labels", {})
if not isinstance(labels, dict):
return
labels["strix-run-id"] = run_id
run_type = os.getenv("STRIX_RUN_TYPE")
if run_type:
labels["strix-run-type"] = run_type
class StrixDockerSandboxSession(DockerSandboxSession):
sandbox_network: str = ""
@@ -235,7 +222,6 @@ class StrixDockerSandboxClient(DockerSandboxClient):
_apply_sandbox_network(create_kwargs)
_apply_resource_limits(create_kwargs)
_apply_log_limits(create_kwargs)
_apply_run_labels(create_kwargs)
# Strix injection: host bind mounts (e.g. large repos passed via --mount)
# that bypass the SDK's file-by-file LocalDir copy.
+42 -69
View File
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any
import pytest
from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from strix.config import ContextSettings
from strix.llm import compaction
@@ -147,35 +146,17 @@ 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),
)
monkeypatch.setattr(compaction, "load_settings", lambda: settings)
def _model_response(text: str) -> Any:
chunk = ResponseOutputText(annotations=[], text=text, type="output_text")
message = ResponseOutputMessage(
id="msg", content=[chunk], role="assistant", status="completed", type="message"
)
return SimpleNamespace(output=[message])
def _patch_summary(monkeypatch: pytest.MonkeyPatch, text: str) -> None:
async def fake_acompletion(**_kwargs: Any) -> Any:
message = SimpleNamespace(content=text)
return SimpleNamespace(choices=[SimpleNamespace(message=message)])
def _patch_summary(
monkeypatch: pytest.MonkeyPatch, text: str, captured: dict[str, Any] | None = None
) -> None:
class FakeModel:
async def get_response(self, **kwargs: Any) -> Any:
if captured is not None:
captured.update(kwargs)
return _model_response(text)
class FakeProvider:
def get_model(self, model_name: str | None) -> Any:
if captured is not None:
captured["model"] = model_name
return FakeModel()
monkeypatch.setattr(compaction, "StrixProvider", FakeProvider)
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
@pytest.mark.asyncio
@@ -208,38 +189,19 @@ async def test_maybe_compact_rewrites_and_keeps_pairs(monkeypatch: pytest.Monkey
async def test_maybe_compact_updates_previous_summary(monkeypatch: pytest.MonkeyPatch) -> None:
# Window large enough to leave real room for the summary instructions.
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
captured: dict[str, Any] = {}
_patch_summary(monkeypatch, "NEW", captured)
captured: dict[str, str] = {}
async def fake_acompletion(**kwargs: Any) -> Any:
captured["prompt"] = kwargs["messages"][0]["content"]
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="NEW"))])
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
prior = compaction._checkpoint_item("OLD SUMMARY TEXT")
session = FakeSession([prior, *_turns(12)])
assert await compaction.maybe_compact(session, model="m", force=True) is True
assert "OLD SUMMARY TEXT" in captured["input"]
@pytest.mark.asyncio
async def test_summarize_routes_through_provider_with_settings(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
monkeypatch.setattr(
compaction,
"load_settings",
lambda: SimpleNamespace(
llm=SimpleNamespace(
api_key=None, api_base=None, timeout=1, extra_headers={"X-Feature-Key": "svc"}
)
),
)
captured: dict[str, Any] = {}
_patch_summary(monkeypatch, "S", captured)
assert await compaction._summarize("litellm/openai/some-model", "p", 64) == "S"
assert captured["model"] == "litellm/openai/some-model"
settings = captured["model_settings"]
assert settings.extra_headers == {"X-Feature-Key": "svc"}
assert settings.max_tokens == 64
assert "OLD SUMMARY TEXT" in captured["prompt"]
def test_fit_to_tokens_truncates_oversized_text(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -271,14 +233,19 @@ def test_summary_output_tokens_capped_at_model_limit(monkeypatch: pytest.MonkeyP
async def test_maybe_compact_bounds_summary_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
# A tiny window with a huge head must not send an oversized summary request.
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
captured: dict[str, Any] = {}
_patch_summary(monkeypatch, "S", captured)
captured: dict[str, str] = {}
async def fake_acompletion(**kwargs: Any) -> Any:
captured["prompt"] = kwargs["messages"][0]["content"]
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="S"))])
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
big_turns = [{"role": "user", "content": "y" * 2_000} for _ in range(50)]
session = FakeSession(big_turns)
assert await compaction.maybe_compact(session, model="m") is True
# count_tokens==len(chars); prompt must fit the model window.
assert len(captured["input"]) <= 4_000
assert len(captured["prompt"]) <= 4_000
@pytest.mark.asyncio
@@ -289,27 +256,27 @@ async def test_summary_request_fits_when_room_is_below_old_floor(
instructions = len(compaction._SUMMARY_INSTRUCTIONS)
window = instructions + 64 + 256 + 300 # summary_max(64)+slack(256)+room(300)
_patch_budget(monkeypatch, keep_tokens=30, window=window)
captured: dict[str, Any] = {}
_patch_summary(monkeypatch, "S", captured)
captured: dict[str, str] = {}
async def fake_acompletion(**kwargs: Any) -> Any:
captured["prompt"] = kwargs["messages"][0]["content"]
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="S"))])
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
session = FakeSession([{"role": "user", "content": "y" * 5_000} for _ in range(20)])
assert await compaction.maybe_compact(session, model="m") is True
assert len(captured["input"]) <= window
assert len(captured["prompt"]) <= window
@pytest.mark.asyncio
async def test_maybe_compact_skips_when_summary_fails(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
class BoomModel:
async def get_response(self, **_kwargs: Any) -> Any:
raise RuntimeError("boom")
async def fake_acompletion(**_kwargs: Any) -> Any:
raise RuntimeError("boom")
class BoomProvider:
def get_model(self, _model_name: str | None) -> Any:
return BoomModel()
monkeypatch.setattr(compaction, "StrixProvider", BoomProvider)
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
session = FakeSession(_turns(12))
before = await session.get_items()
@@ -323,11 +290,17 @@ async def test_maybe_compact_skips_when_no_room_to_summarise(
) -> None:
# No room for any head -> no (doomed) summary is attempted.
_patch_budget(monkeypatch, keep_tokens=30, window=200)
captured: dict[str, Any] = {}
_patch_summary(monkeypatch, "S", captured)
called = False
async def fake_acompletion(**_kwargs: Any) -> Any:
nonlocal called
called = True
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="S"))])
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
session = FakeSession(_turns(12))
before = await session.get_items()
assert await compaction.maybe_compact(session, model="m", force=True) is False
assert not captured
assert called is False
assert await session.get_items() == before
-18
View File
@@ -21,24 +21,6 @@ def test_context_window_strips_provider_prefix() -> None:
assert context_budget.context_window("openai/gpt-4o") == 128_000
def test_context_window_chatgpt_prefix_skips_provider_auth(
monkeypatch: pytest.MonkeyPatch,
) -> None:
context_budget._model_info.cache_clear()
calls: list[str] = []
def _model_info(model: str) -> dict[str, int]:
calls.append(model)
return {"max_input_tokens": 1_050_000, "max_output_tokens": 128_000}
monkeypatch.setattr("strix.llm.context_budget.litellm.get_model_info", _model_info)
try:
assert context_budget.context_window("chatgpt/gpt-5.6-luna") == 1_050_000
assert calls == ["gpt-5.6-luna"]
finally:
context_budget._model_info.cache_clear()
def test_context_window_unmapped_uses_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
context_budget._model_info.cache_clear()
-32
View File
@@ -44,38 +44,6 @@ def test_dedupe_endpoint_sent_per_call() -> None:
assert (settings.extra_args or {})["api_key"] == "dedupe-key"
def test_dedicated_dedupe_model_uses_own_headers_not_main() -> None:
dedupe = DedupeSettings(
STRIX_DEDUPE_MODEL="deepseek/cheap",
DEDUPE_LLM_EXTRA_HEADERS={"X-Dedupe": "yes"},
)
settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300)
assert settings.extra_headers == {"X-Dedupe": "yes"}
def test_dedicated_dedupe_model_gets_no_main_headers_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Main": "secret"}))
loader._cached = None
try:
dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap")
settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300)
assert settings.extra_headers is None
finally:
loader._cached = None
def test_fallback_dedupe_inherits_main_headers(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Main": "svc"}))
loader._cached = None
try:
settings = _dedupe_model_settings(DedupeSettings(), "openai/main-model", 300)
assert settings.extra_headers == {"X-Main": "svc"}
finally:
loader._cached = None
def test_dedupe_defaults_are_empty() -> None:
settings = DedupeSettings()
assert settings.model is None
-326
View File
@@ -1,326 +0,0 @@
"""Tests for LLM_DISABLE_STREAMING: serve the streamed run loop without SSE.
A gateway that rejects ``stream:true`` (or delivers SSE unreliably) breaks the
SDK run loop, which only issues streamed requests. ``_NonStreamingModel`` wraps
the resolved model so each turn makes one non-streaming ``get_response`` and
replays the completed result as a single terminal stream event. A local server
that rejects streamed requests but answers non-streamed ones including a
structured tool call proves the wrapper works where the stock model fails.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import TYPE_CHECKING, Any
import pytest
from agents import Agent, Runner, function_tool
from agents.model_settings import ModelSettings
from agents.models.interface import Model, ModelProvider, ModelTracing
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.run import RunConfig
from openai import AsyncOpenAI, BadRequestError
from openai.types.responses import (
ResponseCompletedEvent,
ResponseFunctionToolCall,
ResponseOutputMessage,
ResponseOutputText,
)
from strix.config import codex, loader
from strix.config.loader import load_settings
from strix.config.models import StrixProvider, _NonStreamingModel
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator
def _tool_call_completion() -> dict[str, Any]:
return {
"id": "chatcmpl-1",
"object": "chat.completion",
"created": 0,
"model": "gw-model",
"choices": [
{
"index": 0,
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "do_thing", "arguments": '{"n": 1}'},
}
],
},
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
}
def _text_completion() -> dict[str, Any]:
return {
"id": "chatcmpl-2",
"object": "chat.completion",
"created": 0,
"model": "gw-model",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "hello from gateway"},
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
_CAPTURED: dict[str, Any] = {}
_PAYLOAD: dict[str, dict[str, Any]] = {"value": _tool_call_completion()}
class _Handler(BaseHTTPRequestHandler):
"""A gateway that only speaks non-streaming Chat Completions."""
def log_message(self, *args: Any) -> None:
pass
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
_CAPTURED.clear()
_CAPTURED.update(body)
if body.get("stream"):
payload = json.dumps(
{"error": {"message": "streaming is not supported by this endpoint"}}
).encode()
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
return
payload = json.dumps(_PAYLOAD["value"]).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
@pytest.fixture
def gateway_url() -> Iterator[str]:
_PAYLOAD["value"] = _tool_call_completion()
server = HTTPServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}/v1"
finally:
server.shutdown()
server.server_close()
def _model(base_url: str) -> OpenAIChatCompletionsModel:
client = AsyncOpenAI(api_key="tok", base_url=base_url)
return OpenAIChatCompletionsModel(model="gw-model", openai_client=client)
def _call_kwargs() -> dict[str, Any]:
return {
"system_instructions": "s",
"input": "hi",
"model_settings": ModelSettings(),
"tools": [],
"output_schema": None,
"handoffs": [],
"tracing": ModelTracing.DISABLED,
"previous_response_id": None,
"conversation_id": None,
"prompt": None,
}
async def _drain(gen: AsyncIterator[Any]) -> list[Any]:
return [event async for event in gen]
@pytest.mark.asyncio
async def test_stock_model_streaming_fails_on_non_streaming_gateway(gateway_url: str) -> None:
# The stock model issues stream:true and the gateway rejects it.
model = _model(gateway_url)
with pytest.raises(BadRequestError, match="streaming is not supported"):
await _drain(model.stream_response(**_call_kwargs()))
assert _CAPTURED["stream"] is True
@pytest.mark.asyncio
async def test_wrapper_streams_tool_call_without_streaming_request(gateway_url: str) -> None:
# The wrapper turns the streamed run-loop call into one non-streaming
# request and replays the completed result as a terminal stream event.
model = _NonStreamingModel(_model(gateway_url))
events = await _drain(model.stream_response(**_call_kwargs()))
assert _CAPTURED.get("stream") is not True
assert len(events) == 1
completed = events[0]
assert isinstance(completed, ResponseCompletedEvent)
tool_call = completed.response.output[0]
assert isinstance(tool_call, ResponseFunctionToolCall)
assert tool_call.name == "do_thing"
assert json.loads(tool_call.arguments) == {"n": 1}
assert completed.response.usage is not None
assert completed.response.usage.total_tokens == 7
@pytest.mark.asyncio
async def test_wrapper_streams_plain_text(gateway_url: str) -> None:
_PAYLOAD["value"] = _text_completion()
model = _NonStreamingModel(_model(gateway_url))
events = await _drain(model.stream_response(**_call_kwargs()))
assert _CAPTURED.get("stream") is not True
message = events[0].response.output[0]
assert isinstance(message, ResponseOutputMessage)
text = message.content[0]
assert isinstance(text, ResponseOutputText)
assert text.text == "hello from gateway"
@pytest.mark.asyncio
async def test_wrapper_get_response_stays_non_streaming(gateway_url: str) -> None:
# The non-streaming path is a plain pass-through to the inner model.
model = _NonStreamingModel(_model(gateway_url))
response = await model.get_response(**_call_kwargs())
assert _CAPTURED.get("stream") is not True
tool_call = response.output[0]
assert isinstance(tool_call, ResponseFunctionToolCall)
assert tool_call.name == "do_thing"
_TURN_STREAM_FLAGS: list[bool] = []
class _MultiTurnHandler(BaseHTTPRequestHandler):
"""Non-streaming gateway: a tool call on turn 1, a final answer on turn 2."""
def log_message(self, *args: Any) -> None:
pass
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
_TURN_STREAM_FLAGS.append(bool(body.get("stream")))
completion = _tool_call_completion() if len(_TURN_STREAM_FLAGS) == 1 else _text_completion()
if len(_TURN_STREAM_FLAGS) > 1:
completion["choices"][0]["message"]["content"] = "all done"
payload = json.dumps(completion).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
@pytest.fixture
def multiturn_url() -> Iterator[str]:
_TURN_STREAM_FLAGS.clear()
server = HTTPServer(("127.0.0.1", 0), _MultiTurnHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}/v1"
finally:
server.shutdown()
server.server_close()
@pytest.mark.asyncio
async def test_run_loop_executes_tool_and_completes_without_streaming(multiturn_url: str) -> None:
# The whole streamed agent loop runs against a non-streaming gateway: the
# synthetic terminal event feeds the runner, which executes the tool and
# continues the turn until a final answer.
calls: list[int] = []
@function_tool
def do_thing(n: int) -> str:
calls.append(n)
return f"did {n}"
class _Provider(ModelProvider):
def get_model(self, model_name: str | None) -> Model: # noqa: ARG002
return _NonStreamingModel(_model(multiturn_url))
agent = Agent(name="t", instructions="use the tool", tools=[do_thing], model="gw-model")
result = Runner.run_streamed(
agent, input="please", run_config=RunConfig(model_provider=_Provider())
)
async for _ in result.stream_events():
pass
assert calls == [1] # tool executed with the streamed tool-call args
assert result.final_output == "all done"
assert len(_TURN_STREAM_FLAGS) == 2 # two turns, both...
assert not any(_TURN_STREAM_FLAGS) # ...issued as non-streaming requests
class _DummyModel(Model):
async def get_response(self, *args: Any, **kwargs: Any) -> Any:
raise NotImplementedError
def stream_response(self, *args: Any, **kwargs: Any) -> Any:
raise NotImplementedError
@pytest.fixture
def _reset_settings(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
for key in ("STRIX_LLM", "LLM_DISABLE_STREAMING"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(loader, "_cached", None)
monkeypatch.setattr(loader, "_override", None)
yield
def test_get_model_wraps_when_disabled(
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
) -> None:
inner = _DummyModel()
monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: inner)
monkeypatch.setenv("LLM_DISABLE_STREAMING", "true")
load_settings()
model = StrixProvider().get_model("openai/gpt-4o-mini")
assert isinstance(model, _NonStreamingModel)
def test_get_model_unwrapped_by_default(
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
) -> None:
inner = _DummyModel()
monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: inner)
load_settings()
model = StrixProvider().get_model("openai/gpt-4o-mini")
assert model is inner
def test_get_model_does_not_wrap_subscription_model(
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
) -> None:
# Subscription (ChatGPT) models are always streamed and must not be wrapped.
monkeypatch.setattr(codex, "subscription_model", lambda *_: "gpt-5.5")
monkeypatch.setattr(codex, "get_subscription_client", lambda: AsyncOpenAI(api_key="x"))
monkeypatch.setenv("LLM_DISABLE_STREAMING", "true")
load_settings()
model = StrixProvider().get_model("gpt-5.5")
assert not isinstance(model, _NonStreamingModel)
-90
View File
@@ -9,10 +9,8 @@ from typing import Any
from unittest.mock import MagicMock
import pytest
from agents.items import MessageOutputItem
from agents.memory import SQLiteSession
from agents.tool_context import ToolContext
from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal
from strix.config import codex
from strix.core import execution
@@ -26,30 +24,6 @@ from strix.core.execution import (
from strix.tools.finish.tool import finish_scan
class _StructuredRefusalStream:
def __init__(self, refusal: str) -> None:
self.run_loop_exception: BaseException | None = None
self.new_items = [
MessageOutputItem(
agent=MagicMock(),
raw_item=ResponseOutputMessage(
id="msg-refusal",
content=[ResponseOutputRefusal(type="refusal", refusal=refusal)],
role="assistant",
status="completed",
type="message",
),
)
]
async def stream_events(self) -> Any:
if False:
yield None
def cancel(self, mode: str = "immediate") -> None: # noqa: ARG002
return
async def _call_finish_scan(
coordinator: AgentCoordinator, agent_id: str, parent_id: str | None
) -> dict[str, Any]:
@@ -570,70 +544,6 @@ async def test_guardrail_noninteractive_fails_only_blocked_agent(tmp_path: Any)
session.close()
@pytest.mark.asyncio
async def test_structured_provider_refusal_fails_interactive_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
refusal = "This request was blocked under the provider's usage policy."
stream = _StructuredRefusalStream(refusal)
monkeypatch.setattr(execution.Runner, "run_streamed", lambda *_args, **_kwargs: stream)
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
result = await execution._run_cycle(
MagicMock(),
coordinator,
"root",
input_data="task",
run_config=MagicMock(),
context={},
max_turns=5,
session=None,
interactive=True,
event_sink=None,
hooks=None,
)
assert result is None
assert coordinator.statuses["root"] == "failed"
assert coordinator.errors["root"] == refusal
@pytest.mark.asyncio
async def test_structured_provider_refusal_fails_noninteractive_child(
tmp_path: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
refusal = "This request was blocked under the provider's usage policy."
stream = _StructuredRefusalStream(refusal)
monkeypatch.setattr(execution.Runner, "run_streamed", lambda *_args, **_kwargs: stream)
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
session = SQLiteSession("root", tmp_path / "agents.db")
await coordinator.attach_runtime("root", session=session)
result = await execution._run_cycle(
MagicMock(),
coordinator,
"child",
input_data="task",
run_config=MagicMock(),
context={"parent_id": "root"},
max_turns=5,
session=None,
interactive=False,
event_sink=None,
hooks=None,
)
assert result is None
assert coordinator.statuses["child"] == "failed"
assert coordinator.errors["child"] == refusal
assert coordinator.pending_counts.get("root", 0) > 0
session.close()
@pytest.mark.asyncio
async def test_resume_revives_guardrail_parked_child_but_not_plain_waiting(
tmp_path: Any, monkeypatch: pytest.MonkeyPatch
-24
View File
@@ -272,30 +272,6 @@ def test_make_model_settings_omits_timeout_when_unset() -> None:
assert settings.extra_args is None
def test_make_model_settings_sets_extra_headers() -> None:
settings = make_model_settings(
"none",
model_name="openai/some-model",
extra_headers={"X-Feature-Key": "svc", "X-Tenant": "acme"},
)
assert settings.extra_headers == {"X-Feature-Key": "svc", "X-Tenant": "acme"}
def test_make_model_settings_omits_extra_headers_when_unset() -> None:
assert make_model_settings("none", model_name="gpt-4o").extra_headers is None
def test_make_model_settings_extra_headers_survive_reasoning_resolve() -> None:
settings = make_model_settings(
"high",
model_name="openai/o3",
extra_headers={"X-Feature-Key": "svc"},
)
assert settings.extra_headers == {"X-Feature-Key": "svc"}
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.
-97
View File
@@ -1,97 +0,0 @@
"""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_extra_headers_applied_to_native_openai_without_custom_base(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("STRIX_LLM", "openai/gpt-5")
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"
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
-1
View File
@@ -40,7 +40,6 @@ async def test_persistent_rate_limit_stops_gracefully(
force_required_tool_choice=False,
timeout=300,
prompt_cache=True,
extra_headers=None,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
-1
View File
@@ -48,7 +48,6 @@ def _patch_engine_scaffold(
force_required_tool_choice=False,
timeout=300,
prompt_cache=True,
extra_headers=None,
),
runtime=types.SimpleNamespace(max_context_images=3),
)