mirror of
https://github.com/usestrix/strix.git
synced 2026-08-20 18:38:57 +02:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d56424090f | ||
|
|
8a458c3187 | ||
|
|
76e97e6a59 | ||
|
|
885b2ca5c5 | ||
|
|
980216860e | ||
|
|
d4e58b2cd0 | ||
|
|
e9ebdc502f | ||
|
|
ebb3a62a99 | ||
|
|
1a2fa89972 | ||
|
|
9de747d135 | ||
|
|
b313d78f60 | ||
|
|
e037d8d727 | ||
|
|
fade37025d | ||
|
|
f968f8e5a7 | ||
|
|
ac0014fe65 | ||
|
|
86282e83a8 |
@@ -19,6 +19,14 @@ Configure Strix using environment variables or a config file.
|
|||||||
Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.
|
Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.
|
||||||
</ParamField>
|
</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">
|
<ParamField path="LLM_TIMEOUT" default="300" type="integer">
|
||||||
Request timeout in seconds for LLM calls.
|
Request timeout in seconds for LLM calls.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
@@ -55,6 +63,12 @@ affecting the agents that do the actual testing.
|
|||||||
model runs on a different endpoint than the main model.
|
model runs on a different endpoint than the main model.
|
||||||
</ParamField>
|
</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">
|
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
|
||||||
Reasoning effort for the deduplication model. Defaults to the model's own
|
Reasoning effort for the deduplication model. Defaults to the model's own
|
||||||
baseline when unset.
|
baseline when unset.
|
||||||
|
|||||||
@@ -54,3 +54,20 @@ If you use LM Studio, vLLM, or other runners:
|
|||||||
export STRIX_LLM="openai/local-model"
|
export STRIX_LLM="openai/local-model"
|
||||||
export LLM_API_BASE="http://localhost:1234/v1" # Adjust port as needed
|
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.
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "strix-agent"
|
name = "strix-agent"
|
||||||
version = "1.4.0"
|
version = "1.4.1"
|
||||||
description = "Open-source AI Hackers for your apps"
|
description = "Open-source AI Hackers for your apps"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
@@ -220,6 +220,7 @@ ignore = [
|
|||||||
# Stdlib HTTP handler overrides (do_GET/do_POST).
|
# Stdlib HTTP handler overrides (do_GET/do_POST).
|
||||||
"strix/interface/auth_cli.py" = ["N802"]
|
"strix/interface/auth_cli.py" = ["N802"]
|
||||||
"tests/test_codex_streaming.py" = ["N802"]
|
"tests/test_codex_streaming.py" = ["N802"]
|
||||||
|
"tests/test_disable_streaming.py" = ["N802"]
|
||||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||||
|
|||||||
+13
-20
@@ -18,12 +18,12 @@ import logging
|
|||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@@ -221,26 +221,19 @@ def _first(query: dict[str, list[str]], key: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _post_form(payload: dict[str, str]) -> dict[str, Any]:
|
def _post_form(payload: dict[str, str]) -> dict[str, Any]:
|
||||||
body = urllib.parse.urlencode(payload).encode("ascii")
|
|
||||||
request = urllib.request.Request( # noqa: S310 - fixed https OAuth endpoint
|
|
||||||
TOKEN_URL,
|
|
||||||
data=body,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"Accept": "application/json",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen( # noqa: S310 # nosec B310 - fixed https endpoint
|
response = requests.post(
|
||||||
request, timeout=_TOKEN_TIMEOUT
|
TOKEN_URL,
|
||||||
) as response:
|
data=payload,
|
||||||
data = json.loads(response.read() or b"{}")
|
headers={"Accept": "application/json"},
|
||||||
except urllib.error.HTTPError as exc:
|
timeout=_TOKEN_TIMEOUT,
|
||||||
detail = exc.read().decode("utf-8", "replace")[:300]
|
)
|
||||||
raise CodexAuthError("token_http_error", f"HTTP {exc.code}: {detail}") from exc
|
except requests.RequestException as exc:
|
||||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
||||||
raise CodexAuthError("unavailable", str(exc)) from exc
|
raise CodexAuthError("unavailable", str(exc)) from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
detail = response.text[:300]
|
||||||
|
raise CodexAuthError("token_http_error", f"HTTP {response.status_code}: {detail}")
|
||||||
|
data = json.loads(response.content or b"{}")
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise CodexAuthError("bad_response", "token endpoint returned non-object")
|
raise CodexAuthError("bad_response", "token endpoint returned non-object")
|
||||||
return data
|
return data
|
||||||
|
|||||||
+224
-4
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import contextlib
|
import contextlib
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from agents import (
|
from agents import (
|
||||||
@@ -13,6 +14,8 @@ from agents import (
|
|||||||
set_tracing_disabled,
|
set_tracing_disabled,
|
||||||
)
|
)
|
||||||
from agents.model_settings import ModelSettings
|
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.multi_provider import MultiProvider
|
||||||
from agents.models.openai_responses import OpenAIResponsesModel
|
from agents.models.openai_responses import OpenAIResponsesModel
|
||||||
from agents.retry import (
|
from agents.retry import (
|
||||||
@@ -21,6 +24,8 @@ from agents.retry import (
|
|||||||
RetryPolicyContext,
|
RetryPolicyContext,
|
||||||
retry_policies,
|
retry_policies,
|
||||||
)
|
)
|
||||||
|
from openai.types.responses import Response, ResponseCompletedEvent
|
||||||
|
from openai.types.responses.response_usage import ResponseUsage
|
||||||
from openai.types.shared import Reasoning
|
from openai.types.shared import Reasoning
|
||||||
|
|
||||||
from strix.config import codex
|
from strix.config import codex
|
||||||
@@ -30,10 +35,17 @@ from strix.config.loader import load_settings
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
from agents.models.interface import Model, ModelProvider
|
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 openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
from openai.types.responses.response_prompt_param import ResponsePromptParam
|
||||||
|
|
||||||
from strix.config.settings import ReasoningEffort, Settings
|
from strix.config.settings import LlmSettings, ReasoningEffort, Settings
|
||||||
|
|
||||||
|
|
||||||
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
||||||
@@ -135,6 +147,124 @@ class _CodexResponsesModel(OpenAIResponsesModel):
|
|||||||
await result
|
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):
|
class StrixProvider(MultiProvider):
|
||||||
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
||||||
so users type ``deepseek/deepseek-chat`` rather than
|
so users type ``deepseek/deepseek-chat`` rather than
|
||||||
@@ -159,14 +289,21 @@ class StrixProvider(MultiProvider):
|
|||||||
return self._get_fallback_provider("litellm"), original_model_name
|
return self._get_fallback_provider("litellm"), original_model_name
|
||||||
|
|
||||||
def get_model(self, model_name: str | None) -> Model:
|
def get_model(self, model_name: str | None) -> Model:
|
||||||
|
llm = load_settings().llm
|
||||||
slug = codex.subscription_model(model_name)
|
slug = codex.subscription_model(model_name)
|
||||||
if slug:
|
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(
|
return _CodexResponsesModel(
|
||||||
slug,
|
slug,
|
||||||
codex.get_subscription_client(),
|
codex.get_subscription_client(),
|
||||||
reasoning_effort=load_settings().llm.reasoning_effort,
|
reasoning_effort=llm.reasoning_effort,
|
||||||
)
|
)
|
||||||
return super().get_model(model_name)
|
model = super().get_model(model_name)
|
||||||
|
if llm.disable_streaming:
|
||||||
|
return _NonStreamingModel(model)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||||
@@ -243,6 +380,7 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
|
|||||||
set_default_openai_api("chat_completions")
|
set_default_openai_api("chat_completions")
|
||||||
else:
|
else:
|
||||||
set_default_openai_api("responses")
|
set_default_openai_api("responses")
|
||||||
|
_configure_extra_headers(llm)
|
||||||
|
|
||||||
|
|
||||||
def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None:
|
def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None:
|
||||||
@@ -277,6 +415,51 @@ def _configure_litellm_compatibility() -> None:
|
|||||||
litellm.suppress_debug_info = True
|
litellm.suppress_debug_info = True
|
||||||
|
|
||||||
_register_litellm_cost_callback()
|
_register_litellm_cost_callback()
|
||||||
|
_install_openrouter_stream_cost_capture()
|
||||||
|
|
||||||
|
|
||||||
|
def _install_openrouter_stream_cost_capture() -> None:
|
||||||
|
"""Preserve OpenRouter's per-stream cost, which LiteLLM drops when streaming.
|
||||||
|
|
||||||
|
OpenRouter reports the real charge in ``usage.cost`` of the final stream
|
||||||
|
chunk, but LiteLLM rebuilds streamed responses from token-only fields and
|
||||||
|
discards it (its non-streamed path stashes the cost in hidden params; the
|
||||||
|
streaming path does not). Every scan streams, so without this the cost is
|
||||||
|
lost and Strix falls back to a cost-map estimate that is missing entirely
|
||||||
|
for new models (e.g. kimi-k3), reporting $0. Subclass the OpenRouter
|
||||||
|
streaming handler to record the cost keyed by response id so the cost
|
||||||
|
callback can recover the exact charge for the matching rebuilt response.
|
||||||
|
"""
|
||||||
|
import litellm
|
||||||
|
from litellm.llms.openrouter.chat.transformation import (
|
||||||
|
OpenRouterChatCompletionStreamingHandler,
|
||||||
|
OpenrouterConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
from strix.report.state import streamed_openrouter_costs
|
||||||
|
|
||||||
|
class _StrixOpenRouterStreamingHandler(OpenRouterChatCompletionStreamingHandler):
|
||||||
|
def chunk_parser(self, chunk: dict[str, Any]) -> Any:
|
||||||
|
stream = super().chunk_parser(chunk)
|
||||||
|
streamed_openrouter_costs.remember(
|
||||||
|
chunk.get("id") or getattr(stream, "id", None), chunk.get("usage")
|
||||||
|
)
|
||||||
|
return stream
|
||||||
|
|
||||||
|
class _StrixOpenrouterConfig(OpenrouterConfig):
|
||||||
|
def get_model_response_iterator(
|
||||||
|
self, streaming_response: Any, sync_stream: bool, json_mode: bool | None = False
|
||||||
|
) -> Any:
|
||||||
|
return _StrixOpenRouterStreamingHandler(
|
||||||
|
streaming_response=streaming_response,
|
||||||
|
sync_stream=sync_stream,
|
||||||
|
json_mode=json_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# LiteLLM's provider-config factory reads litellm.OpenrouterConfig at call
|
||||||
|
# time, so overriding the attribute is enough for the subclass to take
|
||||||
|
# effect. (type: ignore — mypy rejects reassigning a class attribute.)
|
||||||
|
litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
_OPENROUTER_ATTRIBUTION_HEADERS = {
|
_OPENROUTER_ATTRIBUTION_HEADERS = {
|
||||||
@@ -302,6 +485,43 @@ def _configure_openrouter_attribution(model_name: str | None) -> None:
|
|||||||
litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
|
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:
|
def _register_litellm_cost_callback() -> None:
|
||||||
import litellm
|
import litellm
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ class LlmSettings(BaseSettings):
|
|||||||
"OLLAMA_API_BASE",
|
"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")
|
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
|
||||||
force_required_tool_choice: bool = Field(
|
force_required_tool_choice: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -44,6 +48,10 @@ class LlmSettings(BaseSettings):
|
|||||||
default=True,
|
default=True,
|
||||||
alias="STRIX_PROMPT_CACHE",
|
alias="STRIX_PROMPT_CACHE",
|
||||||
)
|
)
|
||||||
|
disable_streaming: bool = Field(
|
||||||
|
default=False,
|
||||||
|
alias="LLM_DISABLE_STREAMING",
|
||||||
|
)
|
||||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||||
|
|
||||||
|
|
||||||
@@ -57,6 +65,10 @@ class DedupeSettings(BaseSettings):
|
|||||||
)
|
)
|
||||||
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
|
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
|
||||||
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
|
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):
|
class ContextSettings(BaseSettings):
|
||||||
|
|||||||
@@ -200,7 +200,9 @@ class AgentCoordinator:
|
|||||||
logger.info("agent.status %s=%s", agent_id, status)
|
logger.info("agent.status %s=%s", agent_id, status)
|
||||||
await self._maybe_snapshot()
|
await self._maybe_snapshot()
|
||||||
|
|
||||||
async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool:
|
async def send(
|
||||||
|
self, target_agent_id: str, message: dict[str, Any], *, interrupt: bool = True
|
||||||
|
) -> bool:
|
||||||
"""Deliver a user/peer message by appending it to the target SDK session."""
|
"""Deliver a user/peer message by appending it to the target SDK session."""
|
||||||
if message.get("from") == "user" and self._budget_paused:
|
if message.get("from") == "user" and self._budget_paused:
|
||||||
await self.resume_from_budget_pause(exclude=target_agent_id)
|
await self.resume_from_budget_pause(exclude=target_agent_id)
|
||||||
@@ -211,7 +213,7 @@ class AgentCoordinator:
|
|||||||
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
|
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
|
||||||
session = runtime.session
|
session = runtime.session
|
||||||
stream = runtime.stream
|
stream = runtime.stream
|
||||||
interrupt = runtime.interrupt_on_message
|
interrupt_on_message = runtime.interrupt_on_message
|
||||||
if session is None:
|
if session is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"agent.send dropped target=%s because its SDK session is not attached",
|
"agent.send dropped target=%s because its SDK session is not attached",
|
||||||
@@ -230,7 +232,7 @@ class AgentCoordinator:
|
|||||||
async with self._lock:
|
async with self._lock:
|
||||||
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
|
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
|
||||||
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
|
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
|
||||||
if stream is not None and interrupt:
|
if stream is not None and interrupt and interrupt_on_message:
|
||||||
stream.cancel(mode="immediate")
|
stream.cancel(mode="immediate")
|
||||||
await self._maybe_snapshot()
|
await self._maybe_snapshot()
|
||||||
return True
|
return True
|
||||||
|
|||||||
+54
-1
@@ -21,6 +21,7 @@ from openai import (
|
|||||||
RateLimitError,
|
RateLimitError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from strix.config import codex
|
||||||
from strix.core.hooks import (
|
from strix.core.hooks import (
|
||||||
BudgetExceededError,
|
BudgetExceededError,
|
||||||
BudgetPausedError,
|
BudgetPausedError,
|
||||||
@@ -54,6 +55,23 @@ _INPUT_REJECTION_CODES = frozenset({400, 404, 422})
|
|||||||
_MAX_COMPACTIONS_PER_CYCLE = 2
|
_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:
|
def _run_config_model(run_config: RunConfig) -> str | None:
|
||||||
return run_config.model if isinstance(run_config.model, str) else None
|
return run_config.model if isinstance(run_config.model, str) else None
|
||||||
|
|
||||||
@@ -88,6 +106,11 @@ async def _compact_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_GUARDRAIL_PARK_ERROR = (
|
||||||
|
"Blocked by the model's content guardrail (flagged as a possible cybersecurity risk). "
|
||||||
|
"Set STRIX_LLM to a model that isn't blocked and resume the scan to continue."
|
||||||
|
)
|
||||||
|
|
||||||
_TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504})
|
_TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504})
|
||||||
_MAX_TRANSIENT_MODEL_RETRIES = 4
|
_MAX_TRANSIENT_MODEL_RETRIES = 4
|
||||||
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
|
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
|
||||||
@@ -304,6 +327,7 @@ async def respawn_subagents(
|
|||||||
if coordinator.parent_of.get(aid) is None or aid == root_id:
|
if coordinator.parent_of.get(aid) is None or aid == root_id:
|
||||||
continue
|
continue
|
||||||
md["_restored_status"] = status
|
md["_restored_status"] = status
|
||||||
|
md["_restored_error"] = coordinator.errors.get(aid)
|
||||||
candidates.append(
|
candidates.append(
|
||||||
(
|
(
|
||||||
aid,
|
aid,
|
||||||
@@ -316,7 +340,8 @@ async def respawn_subagents(
|
|||||||
for child_id, name, parent_id, md in candidates:
|
for child_id, name, parent_id, md in candidates:
|
||||||
try:
|
try:
|
||||||
restored_status = str(md.get("_restored_status") or "running")
|
restored_status = str(md.get("_restored_status") or "running")
|
||||||
start_parked = interactive and restored_status != "running"
|
recoverable_park = restored_status == "waiting" and bool(md.get("_restored_error"))
|
||||||
|
start_parked = interactive and restored_status != "running" and not recoverable_park
|
||||||
|
|
||||||
if start_parked:
|
if start_parked:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -482,6 +507,8 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
logger.exception("stream event sink failed for %s", agent_id)
|
logger.exception("stream event sink failed for %s", agent_id)
|
||||||
if stream.run_loop_exception is not None:
|
if stream.run_loop_exception is not None:
|
||||||
raise stream.run_loop_exception
|
raise stream.run_loop_exception
|
||||||
|
if refusal := _structured_provider_refusal(stream):
|
||||||
|
raise ProviderRefusalError(refusal)
|
||||||
except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError):
|
except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError):
|
||||||
raise
|
raise
|
||||||
except RuntimeError as stream_exc:
|
except RuntimeError as stream_exc:
|
||||||
@@ -572,6 +599,15 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
if session is not None:
|
if session is not None:
|
||||||
input_data = []
|
input_data = []
|
||||||
continue
|
continue
|
||||||
|
if codex.is_content_guardrail_error(exc):
|
||||||
|
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:
|
if not interactive:
|
||||||
raise
|
raise
|
||||||
if isinstance(exc, MaxTurnsExceeded):
|
if isinstance(exc, MaxTurnsExceeded):
|
||||||
@@ -589,6 +625,22 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
return stream
|
return stream
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_content_guardrail(
|
||||||
|
coordinator: AgentCoordinator,
|
||||||
|
agent_id: str,
|
||||||
|
exc: BaseException,
|
||||||
|
*,
|
||||||
|
interactive: bool,
|
||||||
|
) -> RunResultBase | None:
|
||||||
|
logger.warning("agent %s blocked by the model's content guardrail: %s", agent_id, exc)
|
||||||
|
if interactive:
|
||||||
|
await coordinator.set_status(agent_id, "waiting", error=_GUARDRAIL_PARK_ERROR)
|
||||||
|
return None
|
||||||
|
await coordinator.set_status(agent_id, "failed", error=_GUARDRAIL_PARK_ERROR)
|
||||||
|
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _settle_run_result(
|
async def _settle_run_result(
|
||||||
coordinator: AgentCoordinator,
|
coordinator: AgentCoordinator,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
@@ -685,6 +737,7 @@ async def _notify_parent_on_terminal(
|
|||||||
"priority": "high",
|
"priority": "high",
|
||||||
"content": template.format(name=name, agent_id=agent_id),
|
"content": template.format(name=name, agent_id=agent_id),
|
||||||
},
|
},
|
||||||
|
interrupt=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -132,12 +132,14 @@ def make_model_settings(
|
|||||||
force_required_tool_choice: bool = False,
|
force_required_tool_choice: bool = False,
|
||||||
request_timeout: float | None = None,
|
request_timeout: float | None = None,
|
||||||
prompt_cache: bool = True,
|
prompt_cache: bool = True,
|
||||||
|
extra_headers: dict[str, str] | None = None,
|
||||||
) -> ModelSettings:
|
) -> ModelSettings:
|
||||||
model_settings = ModelSettings(
|
model_settings = ModelSettings(
|
||||||
parallel_tool_calls=False,
|
parallel_tool_calls=False,
|
||||||
retry=DEFAULT_MODEL_RETRY,
|
retry=DEFAULT_MODEL_RETRY,
|
||||||
include_usage=True,
|
include_usage=True,
|
||||||
extra_args=request_timeout_extra_args(request_timeout),
|
extra_args=request_timeout_extra_args(request_timeout),
|
||||||
|
extra_headers=dict(extra_headers) if extra_headers else None,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
reasoning_effort is not None
|
reasoning_effort is not None
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ async def run_strix_scan(
|
|||||||
force_required_tool_choice=settings.llm.force_required_tool_choice,
|
force_required_tool_choice=settings.llm.force_required_tool_choice,
|
||||||
request_timeout=settings.llm.timeout,
|
request_timeout=settings.llm.timeout,
|
||||||
prompt_cache=settings.llm.prompt_cache,
|
prompt_cache=settings.llm.prompt_cache,
|
||||||
|
extra_headers=settings.llm.extra_headers,
|
||||||
)
|
)
|
||||||
run_config = RunConfig(
|
run_config = RunConfig(
|
||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
@@ -376,6 +377,12 @@ async def run_strix_scan(
|
|||||||
|
|
||||||
async with coordinator._lock:
|
async with coordinator._lock:
|
||||||
root_status = coordinator.statuses.get(root_id)
|
root_status = coordinator.statuses.get(root_id)
|
||||||
|
root_error = coordinator.errors.get(root_id)
|
||||||
|
|
||||||
|
root_recoverable_park = root_status == "waiting" and bool(root_error)
|
||||||
|
root_start_parked = bool(
|
||||||
|
interactive and is_resume and root_status != "running" and not root_recoverable_park
|
||||||
|
)
|
||||||
|
|
||||||
result = await run_agent_loop(
|
result = await run_agent_loop(
|
||||||
agent=root_agent,
|
agent=root_agent,
|
||||||
@@ -387,7 +394,7 @@ async def run_strix_scan(
|
|||||||
agent_id=root_id,
|
agent_id=root_id,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
session=root_session,
|
session=root_session,
|
||||||
start_parked=bool(interactive and is_resume and root_status != "running"),
|
start_parked=root_start_parked,
|
||||||
event_sink=event_sink,
|
event_sink=event_sink,
|
||||||
hooks=hooks,
|
hooks=hooks,
|
||||||
)
|
)
|
||||||
|
|||||||
+22
-4
@@ -31,7 +31,7 @@ from strix.config.models import (
|
|||||||
is_known_openai_bare_model,
|
is_known_openai_bare_model,
|
||||||
is_recommended_or_frontier_model,
|
is_recommended_or_frontier_model,
|
||||||
)
|
)
|
||||||
from strix.core.inputs import DEFAULT_MAX_TURNS
|
from strix.core.inputs import DEFAULT_MAX_TURNS, make_model_settings
|
||||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||||
from strix.interface.cli import run_cli
|
from strix.interface.cli import run_cli
|
||||||
from strix.interface.tui import run_tui
|
from strix.interface.tui import run_tui
|
||||||
@@ -382,7 +382,13 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
|||||||
model.get_response(
|
model.get_response(
|
||||||
system_instructions="You are a helpful assistant.",
|
system_instructions="You are a helpful assistant.",
|
||||||
input="Reply with just 'OK'.",
|
input="Reply with just 'OK'.",
|
||||||
model_settings=ModelSettings(),
|
model_settings=make_model_settings(
|
||||||
|
None,
|
||||||
|
model_name=raw_model,
|
||||||
|
request_timeout=llm.timeout,
|
||||||
|
prompt_cache=False,
|
||||||
|
extra_headers=llm.extra_headers,
|
||||||
|
),
|
||||||
tools=[],
|
tools=[],
|
||||||
output_schema=None,
|
output_schema=None,
|
||||||
handoffs=[],
|
handoffs=[],
|
||||||
@@ -404,7 +410,19 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
|||||||
# Match the runtime path: send the dedupe key/endpoint per call so a
|
# Match the runtime path: send the dedupe key/endpoint per call so a
|
||||||
# separate-provider dedupe model authenticates during warm-up too.
|
# separate-provider dedupe model authenticates during warm-up too.
|
||||||
deduper_extra = _dedupe_extra_args(settings.dedupe)
|
deduper_extra = _dedupe_extra_args(settings.dedupe)
|
||||||
deduper_settings = ModelSettings(extra_args=deduper_extra or None)
|
# 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))
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
deduper.get_response(
|
deduper.get_response(
|
||||||
system_instructions="You are a helpful assistant.",
|
system_instructions="You are a helpful assistant.",
|
||||||
@@ -884,7 +902,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
|||||||
view_text = Text()
|
view_text = Text()
|
||||||
view_text.append("\n")
|
view_text.append("\n")
|
||||||
view_text.append("View", style="dim")
|
view_text.append("View", style="dim")
|
||||||
view_text.append(" ")
|
view_text.append(" ")
|
||||||
view_text.append(f"strix view {args.run_name}", style="#22c55e")
|
view_text.append(f"strix view {args.run_name}", style="#22c55e")
|
||||||
panel_parts.extend(["\n", view_text])
|
panel_parts.extend(["\n", view_text])
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, ClassVar
|
|||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from pygments.token import _TokenType
|
||||||
from textual.timer import Timer
|
from textual.timer import Timer
|
||||||
|
|
||||||
from rich.align import Align
|
from rich.align import Align
|
||||||
@@ -352,7 +353,7 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
|||||||
if not token_value:
|
if not token_value:
|
||||||
continue
|
continue
|
||||||
color = None
|
color = None
|
||||||
tt = token_type
|
tt: _TokenType | None = token_type
|
||||||
while tt:
|
while tt:
|
||||||
if tt in colors:
|
if tt in colors:
|
||||||
color = colors[tt]
|
color = colors[tt]
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class TuiLiveView:
|
|||||||
self.events: list[dict[str, Any]] = []
|
self.events: list[dict[str, Any]] = []
|
||||||
self._next_event_id = 1
|
self._next_event_id = 1
|
||||||
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
|
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
|
||||||
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {}
|
self._tool_event_by_agent_and_call_id: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
|
||||||
def hydrate_from_run_dir(self, run_dir: Path) -> None:
|
def hydrate_from_run_dir(self, run_dir: Path) -> None:
|
||||||
state_dir = runtime_state_dir(run_dir)
|
state_dir = runtime_state_dir(run_dir)
|
||||||
@@ -223,7 +223,8 @@ class TuiLiveView:
|
|||||||
timestamp: str | None = None,
|
timestamp: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
call_id = call["call_id"]
|
call_id = call["call_id"]
|
||||||
existing = self._tool_event_by_call_id.get(call_id)
|
event_key = (agent_id, call_id)
|
||||||
|
existing = self._tool_event_by_agent_and_call_id.get(event_key)
|
||||||
tool_data = {
|
tool_data = {
|
||||||
"tool_name": call["tool_name"],
|
"tool_name": call["tool_name"],
|
||||||
"args": call["args"],
|
"args": call["args"],
|
||||||
@@ -233,7 +234,7 @@ class TuiLiveView:
|
|||||||
}
|
}
|
||||||
if existing is None:
|
if existing is None:
|
||||||
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
|
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
|
||||||
self._tool_event_by_call_id[call_id] = event
|
self._tool_event_by_agent_and_call_id[event_key] = event
|
||||||
else:
|
else:
|
||||||
existing["data"].update(tool_data)
|
existing["data"].update(tool_data)
|
||||||
self._bump_event(existing, timestamp=timestamp)
|
self._bump_event(existing, timestamp=timestamp)
|
||||||
@@ -249,7 +250,8 @@ class TuiLiveView:
|
|||||||
timestamp: str | None = None,
|
timestamp: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
call_id = output["call_id"]
|
call_id = output["call_id"]
|
||||||
event = self._tool_event_by_call_id.get(call_id)
|
event_key = (agent_id, call_id)
|
||||||
|
event = self._tool_event_by_agent_and_call_id.get(event_key)
|
||||||
if event is None:
|
if event is None:
|
||||||
event = self._append_event(
|
event = self._append_event(
|
||||||
agent_id,
|
agent_id,
|
||||||
@@ -263,7 +265,7 @@ class TuiLiveView:
|
|||||||
},
|
},
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
)
|
)
|
||||||
self._tool_event_by_call_id[call_id] = event
|
self._tool_event_by_agent_and_call_id[event_key] = event
|
||||||
|
|
||||||
result = _parse_json_value(output["output"])
|
result = _parse_json_value(output["output"])
|
||||||
event["data"]["result"] = result
|
event["data"]["result"] = result
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ import tempfile
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import HTTPError, URLError
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
|
import requests
|
||||||
from docker.errors import DockerException, ImageNotFound
|
from docker.errors import DockerException, ImageNotFound
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
@@ -1088,13 +1087,12 @@ def resolve_diff_scope_context(
|
|||||||
def _is_http_git_repo(url: str) -> bool:
|
def _is_http_git_repo(url: str) -> bool:
|
||||||
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
|
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
|
||||||
try:
|
try:
|
||||||
req = Request(check_url, headers={"User-Agent": "git/strix"}) # noqa: S310
|
resp = requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10)
|
||||||
with urlopen(req, timeout=10) as resp: # noqa: S310 # nosec B310
|
except (requests.RequestException, ValueError):
|
||||||
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
|
|
||||||
except HTTPError as e:
|
|
||||||
return e.code == 401
|
|
||||||
except (URLError, OSError, ValueError):
|
|
||||||
return False
|
return False
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return resp.status_code == 401
|
||||||
|
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
|
||||||
|
|
||||||
|
|
||||||
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
|
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import base64
|
|||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from strix.config.loader import load_settings
|
from strix.config.loader import load_settings
|
||||||
|
|
||||||
|
|
||||||
@@ -147,21 +147,17 @@ def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int
|
|||||||
map, not raised.
|
map, not raised.
|
||||||
"""
|
"""
|
||||||
url = f"{_app_url()}{path}"
|
url = f"{_app_url()}{path}"
|
||||||
body = json.dumps(payload).encode("utf-8")
|
|
||||||
request = urllib.request.Request( # noqa: S310 - fixed https relay URL
|
|
||||||
url,
|
|
||||||
data=body,
|
|
||||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 # nosec B310
|
response = requests.post(
|
||||||
return response.status, _parse_body(response.read())
|
url,
|
||||||
except urllib.error.HTTPError as exc:
|
json=payload,
|
||||||
return exc.code, _parse_body(exc.read())
|
headers={"Accept": "application/json"},
|
||||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
logger.warning("relay request to %s failed: %s", path, exc)
|
logger.warning("relay request to %s failed: %s", path, exc)
|
||||||
raise RelayError("unavailable") from exc
|
raise RelayError("unavailable") from exc
|
||||||
|
return response.status_code, _parse_body(response.content)
|
||||||
|
|
||||||
|
|
||||||
def _parse_body(raw: bytes) -> dict[str, Any]:
|
def _parse_body(raw: bytes) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -107,8 +107,11 @@ def resolve_run_dir(base_dir: Path, run_param: str | None, default_run_dir: Path
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
# Name of the cookie carrying the per-process session capability.
|
# Prefix of the cookie carrying the per-process session capability. The bound
|
||||||
SESSION_COOKIE = "strix_viewer_session"
|
# port is appended (``strix_viewer_session_<port>``) because browsers scope
|
||||||
|
# cookies by host only, never by port: concurrent viewers on 127.0.0.1 would
|
||||||
|
# otherwise share one cookie slot and clobber each other's session.
|
||||||
|
SESSION_COOKIE_PREFIX = "strix_viewer_session"
|
||||||
|
|
||||||
|
|
||||||
class _ViewerState:
|
class _ViewerState:
|
||||||
@@ -135,6 +138,9 @@ class _ViewerState:
|
|||||||
# enough to steer a live scan, trigger a report, or browse history --
|
# enough to steer a live scan, trigger a report, or browse history --
|
||||||
# the token is never handed to a caller who merely reaches ``/``.
|
# the token is never handed to a caller who merely reaches ``/``.
|
||||||
self.session_token = secrets.token_urlsafe(32)
|
self.session_token = secrets.token_urlsafe(32)
|
||||||
|
# Finalized in ``serve()`` once the port is known (the server binds
|
||||||
|
# after this state is constructed); see SESSION_COOKIE_PREFIX.
|
||||||
|
self.cookie_name = SESSION_COOKIE_PREFIX
|
||||||
|
|
||||||
|
|
||||||
def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||||
@@ -476,7 +482,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
the browser this process handed the page to can pass. A direct
|
the browser this process handed the page to can pass. A direct
|
||||||
caller on an exposed port has no cookie and is rejected.
|
caller on an exposed port has no cookie and is rejected.
|
||||||
"""
|
"""
|
||||||
supplied = self._cookies().get(SESSION_COOKIE, "")
|
supplied = self._cookies().get(state.cookie_name, "")
|
||||||
return bool(supplied) and secrets.compare_digest(supplied, state.session_token)
|
return bool(supplied) and secrets.compare_digest(supplied, state.session_token)
|
||||||
|
|
||||||
def _token_presented(self, query: dict[str, list[str]]) -> bool:
|
def _token_presented(self, query: dict[str, list[str]]) -> bool:
|
||||||
@@ -512,7 +518,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||||||
# SameSite=Strict (never sent from a cross-site context).
|
# SameSite=Strict (never sent from a cross-site context).
|
||||||
self.send_header(
|
self.send_header(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
f"{SESSION_COOKIE}={state.session_token}; Path=/; HttpOnly; SameSite=Strict",
|
f"{state.cookie_name}={state.session_token}; Path=/; HttpOnly; SameSite=Strict",
|
||||||
)
|
)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(content)
|
self.wfile.write(content)
|
||||||
@@ -586,6 +592,7 @@ def serve(
|
|||||||
|
|
||||||
httpd.daemon_threads = True
|
httpd.daemon_threads = True
|
||||||
bound_port = int(httpd.server_address[1])
|
bound_port = int(httpd.server_address[1])
|
||||||
|
state.cookie_name = f"{SESSION_COOKIE_PREFIX}_{bound_port}"
|
||||||
url = f"http://{host}:{bound_port}"
|
url = f"http://{host}:{bound_port}"
|
||||||
|
|
||||||
thread = threading.Thread(target=httpd.serve_forever, name="strix-viewer", daemon=True)
|
thread = threading.Thread(target=httpd.serve_forever, name="strix-viewer", daemon=True)
|
||||||
|
|||||||
+44
-12
@@ -12,15 +12,20 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import litellm
|
from agents.model_settings import ModelSettings
|
||||||
|
from agents.models.interface import ModelTracing
|
||||||
from litellm.exceptions import BadRequestError, ContextWindowExceededError
|
from litellm.exceptions import BadRequestError, ContextWindowExceededError
|
||||||
|
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||||
|
|
||||||
from strix.config import load_settings
|
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.core.sessions import replace_session_items, session_write_lock
|
||||||
from strix.llm.context_budget import context_window, count_tokens, output_limit
|
from strix.llm.context_budget import context_window, count_tokens, output_limit
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from agents.items import ModelResponse
|
||||||
from agents.memory import Session
|
from agents.memory import Session
|
||||||
|
|
||||||
|
|
||||||
@@ -268,26 +273,53 @@ 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:
|
async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
|
||||||
llm = load_settings().llm
|
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:
|
try:
|
||||||
response = await litellm.acompletion(
|
response = (
|
||||||
model=model,
|
await StrixProvider()
|
||||||
messages=[{"role": "user", "content": prompt}],
|
.get_model(model)
|
||||||
max_tokens=max_tokens,
|
.get_response(
|
||||||
api_key=llm.api_key,
|
system_instructions=None,
|
||||||
api_base=llm.api_base,
|
input=prompt,
|
||||||
timeout=llm.timeout,
|
model_settings=model_settings,
|
||||||
|
tools=[],
|
||||||
|
output_schema=None,
|
||||||
|
handoffs=[],
|
||||||
|
tracing=ModelTracing.DISABLED,
|
||||||
|
previous_response_id=None,
|
||||||
|
conversation_id=None,
|
||||||
|
prompt=None,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("compaction summary call failed for model %s", model)
|
logger.exception("compaction summary call failed for model %s", model)
|
||||||
return None
|
return None
|
||||||
try:
|
content = _extract_text(response).strip()
|
||||||
content = response.choices[0].message.content
|
if not content:
|
||||||
except (AttributeError, IndexError, KeyError):
|
|
||||||
logger.warning("compaction summary returned no content")
|
logger.warning("compaction summary returned no content")
|
||||||
return None
|
return None
|
||||||
return content.strip() if isinstance(content, str) and content.strip() else None
|
return content
|
||||||
|
|
||||||
|
|
||||||
async def maybe_compact(
|
async def maybe_compact(
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# LiteLLM keys models without the routing prefix users type (``openai/``,
|
# LiteLLM keys models without the routing prefix users type (``openai/``,
|
||||||
# ``litellm/``, ``ollama/`` ...). Strip a leading provider segment on lookup.
|
# ``litellm/``, ``ollama/`` ...). Strip a leading provider segment on lookup.
|
||||||
_STRIPPABLE_PREFIXES = ("openai/", "litellm/", "any-llm/", "ollama/", "ollama_chat/")
|
_STRIPPABLE_PREFIXES = (
|
||||||
|
"openai/",
|
||||||
|
"chatgpt/",
|
||||||
|
"litellm/",
|
||||||
|
"any-llm/",
|
||||||
|
"ollama/",
|
||||||
|
"ollama_chat/",
|
||||||
|
)
|
||||||
|
|
||||||
_DEFAULT_OUTPUT_TOKENS = 8_192
|
_DEFAULT_OUTPUT_TOKENS = 8_192
|
||||||
|
|
||||||
@@ -38,7 +45,11 @@ def _safe_get_model_info(model: str) -> dict[str, Any] | None:
|
|||||||
|
|
||||||
@lru_cache(maxsize=128)
|
@lru_cache(maxsize=128)
|
||||||
def _model_info(model: str) -> dict[str, int]:
|
def _model_info(model: str) -> dict[str, int]:
|
||||||
for candidate in (model, _lookup_key(model)):
|
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:
|
||||||
info = _safe_get_model_info(candidate)
|
info = _safe_get_model_info(candidate)
|
||||||
if info is not None:
|
if info is not None:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -51,17 +51,24 @@ def _dedupe_extra_args(dedupe: DedupeSettings) -> dict[str, str]:
|
|||||||
def _dedupe_model_settings(
|
def _dedupe_model_settings(
|
||||||
dedupe: DedupeSettings, model_name: str, request_timeout: float | None
|
dedupe: DedupeSettings, model_name: str, request_timeout: float | None
|
||||||
) -> ModelSettings:
|
) -> ModelSettings:
|
||||||
|
llm = load_settings().llm
|
||||||
settings = make_model_settings(
|
settings = make_model_settings(
|
||||||
dedupe.reasoning_effort,
|
dedupe.reasoning_effort,
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
force_required_tool_choice=False,
|
force_required_tool_choice=False,
|
||||||
request_timeout=request_timeout,
|
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)
|
extra = _dedupe_extra_args(dedupe)
|
||||||
if extra:
|
if extra:
|
||||||
settings = settings.resolve(ModelSettings(extra_args=extra))
|
settings = settings.resolve(ModelSettings(extra_args=extra))
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
|
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
|
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
|
||||||
as any existing report.
|
as any existing report.
|
||||||
@@ -347,9 +354,7 @@ async def check_duplicate(
|
|||||||
response = await model.get_response(
|
response = await model.get_response(
|
||||||
system_instructions=DEDUPE_SYSTEM_PROMPT,
|
system_instructions=DEDUPE_SYSTEM_PROMPT,
|
||||||
input=user_msg,
|
input=user_msg,
|
||||||
model_settings=_dedupe_model_settings(
|
model_settings=_dedupe_model_settings(dedupe, resolved_model, settings.llm.timeout),
|
||||||
dedupe, resolved_model, settings.llm.timeout
|
|
||||||
),
|
|
||||||
tools=[],
|
tools=[],
|
||||||
output_schema=None,
|
output_schema=None,
|
||||||
handoffs=[],
|
handoffs=[],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from importlib.metadata import PackageNotFoundError, version
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
@@ -95,6 +96,8 @@ def get_global_report_state() -> Optional["ReportState"]:
|
|||||||
def set_global_report_state(report_state: "ReportState") -> None:
|
def set_global_report_state(report_state: "ReportState") -> None:
|
||||||
global _global_report_state # noqa: PLW0603
|
global _global_report_state # noqa: PLW0603
|
||||||
_global_report_state = report_state
|
_global_report_state = report_state
|
||||||
|
# New run: drop any streamed-cost entries a prior run left unconsumed.
|
||||||
|
streamed_openrouter_costs.clear()
|
||||||
|
|
||||||
|
|
||||||
class ReportState:
|
class ReportState:
|
||||||
@@ -507,6 +510,72 @@ class ReportState:
|
|||||||
self._sync_llm_usage_record()
|
self._sync_llm_usage_record()
|
||||||
|
|
||||||
|
|
||||||
|
def openrouter_stream_cost(usage: Any) -> float | None:
|
||||||
|
"""Total OpenRouter-reported cost from a raw stream ``usage`` block, or None.
|
||||||
|
|
||||||
|
Non-BYOK responses bill everything to ``usage.cost``. BYOK responses put the
|
||||||
|
OpenRouter fee in ``usage.cost`` (often 0) and the provider charge in
|
||||||
|
``usage.cost_details.upstream_inference_cost``, so BYOK totals sum the two.
|
||||||
|
"""
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
return None
|
||||||
|
total = 0.0
|
||||||
|
cost = usage.get("cost")
|
||||||
|
if isinstance(cost, int | float) and cost > 0:
|
||||||
|
total += float(cost)
|
||||||
|
if bool(usage.get("is_byok")):
|
||||||
|
details = usage.get("cost_details")
|
||||||
|
upstream = details.get("upstream_inference_cost") if isinstance(details, dict) else None
|
||||||
|
if isinstance(upstream, int | float) and upstream > 0:
|
||||||
|
total += float(upstream)
|
||||||
|
return total if total > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _response_id(completion_response: Any) -> str | None:
|
||||||
|
response_id = getattr(completion_response, "id", None)
|
||||||
|
if response_id is None and isinstance(completion_response, dict):
|
||||||
|
response_id = cast("dict[str, Any]", completion_response).get("id")
|
||||||
|
return response_id if isinstance(response_id, str) and response_id else None
|
||||||
|
|
||||||
|
|
||||||
|
class StreamedOpenRouterCosts:
|
||||||
|
"""Correlates OpenRouter's per-stream cost from the parser to the cost callback.
|
||||||
|
|
||||||
|
LiteLLM rebuilds streamed responses from token-only chunks and drops the
|
||||||
|
``usage.cost`` OpenRouter reports in its final stream chunk (its non-streamed
|
||||||
|
path preserves it; streaming snapshots hidden params at stream start). Every
|
||||||
|
scan streams, so the OpenRouter streaming handler (see strix.config.models)
|
||||||
|
records the cost here keyed by response id, and the callback takes it back out
|
||||||
|
for the matching rebuilt response. Entries are removed on read; ``clear()``
|
||||||
|
runs per scan so nothing accumulates across runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._costs: dict[str, float] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def remember(self, response_id: Any, usage: Any) -> None:
|
||||||
|
cost = openrouter_stream_cost(usage)
|
||||||
|
if cost is None or not (isinstance(response_id, str) and response_id):
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self._costs[response_id] = cost
|
||||||
|
|
||||||
|
def take(self, completion_response: Any) -> float | None:
|
||||||
|
response_id = _response_id(completion_response)
|
||||||
|
if response_id is None:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
return self._costs.pop(response_id, None)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._costs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
streamed_openrouter_costs = StreamedOpenRouterCosts()
|
||||||
|
|
||||||
|
|
||||||
def litellm_cost_callback(
|
def litellm_cost_callback(
|
||||||
kwargs: Any,
|
kwargs: Any,
|
||||||
completion_response: Any,
|
completion_response: Any,
|
||||||
@@ -541,6 +610,11 @@ def litellm_cost_callback(
|
|||||||
if cost is None:
|
if cost is None:
|
||||||
cost = _usage_reported_cost(completion_response)
|
cost = _usage_reported_cost(completion_response)
|
||||||
|
|
||||||
|
# Recover the exact OpenRouter cost the streaming handler stashed for this
|
||||||
|
# response — LiteLLM drops it from streamed usage, so nothing above sees it.
|
||||||
|
if cost is None:
|
||||||
|
cost = streamed_openrouter_costs.take(completion_response)
|
||||||
|
|
||||||
if cost is None:
|
if cost is None:
|
||||||
cost = _estimate_response_cost(kwargs, completion_response)
|
cost = _estimate_response_cost(kwargs, completion_response)
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,19 @@ 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):
|
class StrixDockerSandboxSession(DockerSandboxSession):
|
||||||
sandbox_network: str = ""
|
sandbox_network: str = ""
|
||||||
|
|
||||||
@@ -222,6 +235,7 @@ class StrixDockerSandboxClient(DockerSandboxClient):
|
|||||||
_apply_sandbox_network(create_kwargs)
|
_apply_sandbox_network(create_kwargs)
|
||||||
_apply_resource_limits(create_kwargs)
|
_apply_resource_limits(create_kwargs)
|
||||||
_apply_log_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)
|
# Strix injection: host bind mounts (e.g. large repos passed via --mount)
|
||||||
# that bypass the SDK's file-by-file LocalDir copy.
|
# that bypass the SDK's file-by-file LocalDir copy.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from strix.config import load_settings
|
from strix.config import load_settings
|
||||||
from strix.telemetry._common import (
|
from strix.telemetry._common import (
|
||||||
SESSION_ID,
|
SESSION_ID,
|
||||||
@@ -37,13 +37,7 @@ def _send(event: str, properties: dict[str, Any]) -> bool:
|
|||||||
"distinct_id": SESSION_ID,
|
"distinct_id": SESSION_ID,
|
||||||
"properties": properties,
|
"properties": properties,
|
||||||
}
|
}
|
||||||
req = urllib.request.Request( # noqa: S310
|
requests.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=10)
|
||||||
f"{_POSTHOG_HOST}/capture/",
|
|
||||||
data=json.dumps(payload).encode(),
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=10): # noqa: S310 # nosec B310
|
|
||||||
pass
|
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.debug("posthog send failed for event %s", event, exc_info=True)
|
logger.debug("posthog send failed for event %s", event, exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from strix.config import load_settings
|
from strix.config import load_settings
|
||||||
from strix.telemetry._common import (
|
from strix.telemetry._common import (
|
||||||
SESSION_ID,
|
SESSION_ID,
|
||||||
@@ -42,9 +43,7 @@ def _send(event: str, properties: dict[str, Any]) -> bool:
|
|||||||
url = f"{_SCARF_ENDPOINT}{path}"
|
url = f"{_SCARF_ENDPOINT}{path}"
|
||||||
if query:
|
if query:
|
||||||
url = f"{url}?{query}"
|
url = f"{url}?{query}"
|
||||||
req = urllib.request.Request(url, method="POST") # noqa: S310
|
requests.post(url, timeout=10)
|
||||||
with urllib.request.urlopen(req, timeout=10): # noqa: S310 # nosec B310
|
|
||||||
pass
|
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.debug("scarf send failed for event %s", event, exc_info=True)
|
logger.debug("scarf send failed for event %s", event, exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
from strix.config import codex
|
from strix.config import codex
|
||||||
|
|
||||||
@@ -52,6 +54,18 @@ def test_authorize_url_carries_pkce_and_client() -> None:
|
|||||||
assert "state=st8" in url
|
assert "state=st8" in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_form_returns_parsed_body() -> None:
|
||||||
|
resp = mock.MagicMock()
|
||||||
|
resp.status_code = 200
|
||||||
|
resp.content = b'{"access_token": "tok"}'
|
||||||
|
|
||||||
|
with mock.patch.object(requests, "post", return_value=resp) as post:
|
||||||
|
data = codex._post_form({"grant_type": "refresh_token"})
|
||||||
|
|
||||||
|
assert data == {"access_token": "tok"}
|
||||||
|
assert post.call_args.kwargs["timeout"] == codex._TOKEN_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("value", "expected"),
|
("value", "expected"),
|
||||||
[
|
[
|
||||||
|
|||||||
+69
-42
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError
|
from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError
|
||||||
|
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||||
|
|
||||||
from strix.config import ContextSettings
|
from strix.config import ContextSettings
|
||||||
from strix.llm import compaction
|
from strix.llm import compaction
|
||||||
@@ -146,17 +147,35 @@ def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window:
|
|||||||
context.auto_compact = True
|
context.auto_compact = True
|
||||||
settings = SimpleNamespace(
|
settings = SimpleNamespace(
|
||||||
context=context,
|
context=context,
|
||||||
llm=SimpleNamespace(api_key=None, api_base=None, timeout=1),
|
llm=SimpleNamespace(api_key=None, api_base=None, timeout=1, extra_headers=None),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(compaction, "load_settings", lambda: settings)
|
monkeypatch.setattr(compaction, "load_settings", lambda: settings)
|
||||||
|
|
||||||
|
|
||||||
def _patch_summary(monkeypatch: pytest.MonkeyPatch, text: str) -> None:
|
def _model_response(text: str) -> Any:
|
||||||
async def fake_acompletion(**_kwargs: Any) -> Any:
|
chunk = ResponseOutputText(annotations=[], text=text, type="output_text")
|
||||||
message = SimpleNamespace(content=text)
|
message = ResponseOutputMessage(
|
||||||
return SimpleNamespace(choices=[SimpleNamespace(message=message)])
|
id="msg", content=[chunk], role="assistant", status="completed", type="message"
|
||||||
|
)
|
||||||
|
return SimpleNamespace(output=[message])
|
||||||
|
|
||||||
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -189,19 +208,38 @@ async def test_maybe_compact_rewrites_and_keeps_pairs(monkeypatch: pytest.Monkey
|
|||||||
async def test_maybe_compact_updates_previous_summary(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_maybe_compact_updates_previous_summary(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
# Window large enough to leave real room for the summary instructions.
|
# Window large enough to leave real room for the summary instructions.
|
||||||
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
||||||
captured: dict[str, str] = {}
|
captured: dict[str, Any] = {}
|
||||||
|
_patch_summary(monkeypatch, "NEW", captured)
|
||||||
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")
|
prior = compaction._checkpoint_item("OLD SUMMARY TEXT")
|
||||||
session = FakeSession([prior, *_turns(12)])
|
session = FakeSession([prior, *_turns(12)])
|
||||||
|
|
||||||
assert await compaction.maybe_compact(session, model="m", force=True) is True
|
assert await compaction.maybe_compact(session, model="m", force=True) is True
|
||||||
assert "OLD SUMMARY TEXT" in captured["prompt"]
|
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
|
||||||
|
|
||||||
|
|
||||||
def test_fit_to_tokens_truncates_oversized_text(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_fit_to_tokens_truncates_oversized_text(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -233,19 +271,14 @@ def test_summary_output_tokens_capped_at_model_limit(monkeypatch: pytest.MonkeyP
|
|||||||
async def test_maybe_compact_bounds_summary_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
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.
|
# A tiny window with a huge head must not send an oversized summary request.
|
||||||
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
||||||
captured: dict[str, str] = {}
|
captured: dict[str, Any] = {}
|
||||||
|
_patch_summary(monkeypatch, "S", captured)
|
||||||
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)]
|
big_turns = [{"role": "user", "content": "y" * 2_000} for _ in range(50)]
|
||||||
session = FakeSession(big_turns)
|
session = FakeSession(big_turns)
|
||||||
|
|
||||||
assert await compaction.maybe_compact(session, model="m") is True
|
assert await compaction.maybe_compact(session, model="m") is True
|
||||||
# count_tokens==len(chars); prompt must fit the model window.
|
# count_tokens==len(chars); prompt must fit the model window.
|
||||||
assert len(captured["prompt"]) <= 4_000
|
assert len(captured["input"]) <= 4_000
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -256,27 +289,27 @@ async def test_summary_request_fits_when_room_is_below_old_floor(
|
|||||||
instructions = len(compaction._SUMMARY_INSTRUCTIONS)
|
instructions = len(compaction._SUMMARY_INSTRUCTIONS)
|
||||||
window = instructions + 64 + 256 + 300 # summary_max(64)+slack(256)+room(300)
|
window = instructions + 64 + 256 + 300 # summary_max(64)+slack(256)+room(300)
|
||||||
_patch_budget(monkeypatch, keep_tokens=30, window=window)
|
_patch_budget(monkeypatch, keep_tokens=30, window=window)
|
||||||
captured: dict[str, str] = {}
|
captured: dict[str, Any] = {}
|
||||||
|
_patch_summary(monkeypatch, "S", captured)
|
||||||
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)])
|
session = FakeSession([{"role": "user", "content": "y" * 5_000} for _ in range(20)])
|
||||||
|
|
||||||
assert await compaction.maybe_compact(session, model="m") is True
|
assert await compaction.maybe_compact(session, model="m") is True
|
||||||
assert len(captured["prompt"]) <= window
|
assert len(captured["input"]) <= window
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_maybe_compact_skips_when_summary_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_maybe_compact_skips_when_summary_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
||||||
|
|
||||||
async def fake_acompletion(**_kwargs: Any) -> Any:
|
class BoomModel:
|
||||||
raise RuntimeError("boom")
|
async def get_response(self, **_kwargs: Any) -> Any:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
|
class BoomProvider:
|
||||||
|
def get_model(self, _model_name: str | None) -> Any:
|
||||||
|
return BoomModel()
|
||||||
|
|
||||||
|
monkeypatch.setattr(compaction, "StrixProvider", BoomProvider)
|
||||||
session = FakeSession(_turns(12))
|
session = FakeSession(_turns(12))
|
||||||
before = await session.get_items()
|
before = await session.get_items()
|
||||||
|
|
||||||
@@ -290,17 +323,11 @@ async def test_maybe_compact_skips_when_no_room_to_summarise(
|
|||||||
) -> None:
|
) -> None:
|
||||||
# No room for any head -> no (doomed) summary is attempted.
|
# No room for any head -> no (doomed) summary is attempted.
|
||||||
_patch_budget(monkeypatch, keep_tokens=30, window=200)
|
_patch_budget(monkeypatch, keep_tokens=30, window=200)
|
||||||
called = False
|
captured: dict[str, Any] = {}
|
||||||
|
_patch_summary(monkeypatch, "S", captured)
|
||||||
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))
|
session = FakeSession(_turns(12))
|
||||||
before = await session.get_items()
|
before = await session.get_items()
|
||||||
|
|
||||||
assert await compaction.maybe_compact(session, model="m", force=True) is False
|
assert await compaction.maybe_compact(session, model="m", force=True) is False
|
||||||
assert called is False
|
assert not captured
|
||||||
assert await session.get_items() == before
|
assert await session.get_items() == before
|
||||||
|
|||||||
@@ -21,6 +21,24 @@ def test_context_window_strips_provider_prefix() -> None:
|
|||||||
assert context_budget.context_window("openai/gpt-4o") == 128_000
|
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:
|
def test_context_window_unmapped_uses_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
context_budget._model_info.cache_clear()
|
context_budget._model_info.cache_clear()
|
||||||
|
|
||||||
|
|||||||
+105
-2
@@ -7,9 +7,25 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
import pytest
|
import pytest
|
||||||
|
from litellm.types.utils import LlmProviders
|
||||||
|
from litellm.utils import ProviderConfigManager
|
||||||
|
|
||||||
from strix.config.models import _configure_litellm_compatibility
|
from strix.config.models import (
|
||||||
from strix.report.state import litellm_cost_callback
|
_configure_litellm_compatibility,
|
||||||
|
_install_openrouter_stream_cost_capture,
|
||||||
|
)
|
||||||
|
from strix.report.state import (
|
||||||
|
ReportState,
|
||||||
|
litellm_cost_callback,
|
||||||
|
openrouter_stream_cost,
|
||||||
|
set_global_report_state,
|
||||||
|
streamed_openrouter_costs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_streamed_costs() -> None:
|
||||||
|
streamed_openrouter_costs.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_streaming_logging_stays_enabled_for_cost_callback() -> None:
|
def test_streaming_logging_stays_enabled_for_cost_callback() -> None:
|
||||||
@@ -151,3 +167,90 @@ def test_cost_callback_records_nothing_when_no_cost_available() -> None:
|
|||||||
litellm_cost_callback({"response_cost": None, "model": "x/y"}, response)
|
litellm_cost_callback({"response_cost": None, "model": "x/y"}, response)
|
||||||
|
|
||||||
report_state.record_observed_llm_cost.assert_not_called()
|
report_state.record_observed_llm_cost.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_openrouter_stream_cost_extracts_plain_and_byok_totals() -> None:
|
||||||
|
assert openrouter_stream_cost({"cost": 0.003168}) == pytest.approx(0.003168)
|
||||||
|
assert openrouter_stream_cost(
|
||||||
|
{"cost": 0.01, "is_byok": True, "cost_details": {"upstream_inference_cost": 0.2}}
|
||||||
|
) == pytest.approx(0.21)
|
||||||
|
# Upstream cost is only added for BYOK responses.
|
||||||
|
assert openrouter_stream_cost(
|
||||||
|
{"cost": 0.05, "is_byok": False, "cost_details": {"upstream_inference_cost": 0.04}}
|
||||||
|
) == pytest.approx(0.05)
|
||||||
|
assert openrouter_stream_cost({"prompt_tokens": 10}) is None
|
||||||
|
assert openrouter_stream_cost(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cost_callback_recovers_streamed_openrouter_cost_by_response_id() -> None:
|
||||||
|
report_state = MagicMock()
|
||||||
|
streamed_openrouter_costs.remember("gen-abc", {"cost": 0.42})
|
||||||
|
# LiteLLM strips cost from the rebuilt streamed usage; only the id survives.
|
||||||
|
response = SimpleNamespace(id="gen-abc", usage=SimpleNamespace(cost=None), _hidden_params={})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("strix.report.state.get_global_report_state", return_value=report_state),
|
||||||
|
patch("litellm.completion_cost", side_effect=ValueError("unknown model")),
|
||||||
|
):
|
||||||
|
litellm_cost_callback({"response_cost": None, "model": "moonshotai/kimi-k3"}, response)
|
||||||
|
|
||||||
|
report_state.record_observed_llm_cost.assert_called_once_with(0.42)
|
||||||
|
# The entry is consumed so a later response cannot double-count it.
|
||||||
|
assert streamed_openrouter_costs.take(response) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_streamed_openrouter_cost_prefers_provider_report_over_estimate() -> None:
|
||||||
|
report_state = MagicMock()
|
||||||
|
streamed_openrouter_costs.remember("gen-xyz", {"cost": 0.9})
|
||||||
|
response = SimpleNamespace(
|
||||||
|
id="gen-xyz",
|
||||||
|
usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
||||||
|
_hidden_params={},
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("strix.report.state.get_global_report_state", return_value=report_state),
|
||||||
|
patch("litellm.completion_cost", return_value=0.1) as estimate,
|
||||||
|
):
|
||||||
|
litellm_cost_callback({"response_cost": None, "model": "moonshotai/kimi-k3"}, response)
|
||||||
|
|
||||||
|
report_state.record_observed_llm_cost.assert_called_once_with(0.9)
|
||||||
|
estimate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_streamed_openrouter_costs_ignores_entries_without_cost() -> None:
|
||||||
|
streamed_openrouter_costs.remember("gen-none", {"prompt_tokens": 10})
|
||||||
|
streamed_openrouter_costs.remember("", {"cost": 0.5})
|
||||||
|
assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-none")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_streamed_openrouter_costs_cleared_on_new_run() -> None:
|
||||||
|
streamed_openrouter_costs.remember("gen-stale", {"cost": 0.7})
|
||||||
|
set_global_report_state(ReportState.__new__(ReportState))
|
||||||
|
assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stale")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_openrouter_stream_handler_records_cost() -> None:
|
||||||
|
_install_openrouter_stream_cost_capture()
|
||||||
|
# Resolve the config the way LiteLLM does in production so we prove the
|
||||||
|
# override is actually reachable through provider resolution, not just as a
|
||||||
|
# directly-constructed class.
|
||||||
|
config = ProviderConfigManager.get_provider_chat_config(
|
||||||
|
model="moonshotai/kimi-k3", provider=LlmProviders.OPENROUTER
|
||||||
|
)
|
||||||
|
assert config is not None
|
||||||
|
assert type(config).__name__ == "_StrixOpenrouterConfig"
|
||||||
|
handler = config.get_model_response_iterator(streaming_response=iter([]), sync_stream=True)
|
||||||
|
|
||||||
|
chunk = {
|
||||||
|
"id": "gen-stream",
|
||||||
|
"created": 1,
|
||||||
|
"model": "moonshotai/kimi-k3",
|
||||||
|
"choices": [{"index": 0, "delta": {"content": None}}],
|
||||||
|
"usage": {"prompt_tokens": 89, "completion_tokens": 138, "cost": 0.0035055},
|
||||||
|
}
|
||||||
|
handler.chunk_parser(chunk)
|
||||||
|
|
||||||
|
assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stream")) == pytest.approx(
|
||||||
|
0.0035055
|
||||||
|
)
|
||||||
|
|||||||
@@ -44,6 +44,38 @@ def test_dedupe_endpoint_sent_per_call() -> None:
|
|||||||
assert (settings.extra_args or {})["api_key"] == "dedupe-key"
|
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:
|
def test_dedupe_defaults_are_empty() -> None:
|
||||||
settings = DedupeSettings()
|
settings = DedupeSettings()
|
||||||
assert settings.model is None
|
assert settings.model is None
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""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)
|
||||||
+202
-1
@@ -6,16 +6,50 @@ import asyncio
|
|||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from agents.items import MessageOutputItem
|
||||||
from agents.memory import SQLiteSession
|
from agents.memory import SQLiteSession
|
||||||
from agents.tool_context import ToolContext
|
from agents.tool_context import ToolContext
|
||||||
|
from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal
|
||||||
|
|
||||||
|
from strix.config import codex
|
||||||
|
from strix.core import execution
|
||||||
from strix.core.agents import AgentCoordinator
|
from strix.core.agents import AgentCoordinator
|
||||||
from strix.core.execution import _notify_parent_on_terminal, _notify_root_on_budget_reserve
|
from strix.core.execution import (
|
||||||
|
_handle_content_guardrail,
|
||||||
|
_notify_parent_on_terminal,
|
||||||
|
_notify_root_on_budget_reserve,
|
||||||
|
respawn_subagents,
|
||||||
|
)
|
||||||
from strix.tools.finish.tool import finish_scan
|
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(
|
async def _call_finish_scan(
|
||||||
coordinator: AgentCoordinator, agent_id: str, parent_id: str | None
|
coordinator: AgentCoordinator, agent_id: str, parent_id: str | None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -465,3 +499,170 @@ async def test_notify_parent_on_terminal_ignores_non_terminal_status(tmp_path: A
|
|||||||
|
|
||||||
assert coordinator.pending_counts.get("root", 0) == 0
|
assert coordinator.pending_counts.get("root", 0) == 0
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingStream:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.cancelled = False
|
||||||
|
self.cancel_mode: str | None = None
|
||||||
|
|
||||||
|
def cancel(self, mode: str = "immediate") -> None:
|
||||||
|
self.cancelled = True
|
||||||
|
self.cancel_mode = mode
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminal_notice_does_not_cancel_parent_stream(tmp_path: Any) -> None:
|
||||||
|
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")
|
||||||
|
stream = _RecordingStream()
|
||||||
|
await coordinator.attach_runtime("root", session=session, interrupt_on_message=True)
|
||||||
|
await coordinator.attach_stream("root", stream)
|
||||||
|
|
||||||
|
await _notify_parent_on_terminal(coordinator, "child", "crashed")
|
||||||
|
|
||||||
|
assert stream.cancelled is False
|
||||||
|
assert coordinator.pending_counts.get("root", 0) > 0
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_guardrail_interactive_parks_agent_wakeable(tmp_path: Any) -> None:
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
await coordinator.register("child", "recon", parent_id="root")
|
||||||
|
exc = codex.CodexContentGuardrailError("chatgpt/gpt-5.6-sol")
|
||||||
|
|
||||||
|
result = await _handle_content_guardrail(coordinator, "child", exc, interactive=True)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert coordinator.statuses["child"] == "waiting"
|
||||||
|
assert "STRIX_LLM" in coordinator.errors["child"]
|
||||||
|
|
||||||
|
waiter = asyncio.create_task(coordinator.wait_for_message("child"))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert not waiter.done()
|
||||||
|
session = SQLiteSession("child", tmp_path / "agents.db")
|
||||||
|
await coordinator.attach_runtime("child", session=session)
|
||||||
|
await coordinator.send("child", {"from": "user", "content": "switched model, resume"})
|
||||||
|
await asyncio.wait_for(waiter, timeout=1.0)
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_guardrail_noninteractive_fails_only_blocked_agent(tmp_path: Any) -> None:
|
||||||
|
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)
|
||||||
|
exc = codex.CodexContentGuardrailError("chatgpt/gpt-5.6-sol")
|
||||||
|
|
||||||
|
result = await _handle_content_guardrail(coordinator, "child", exc, interactive=False)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert coordinator.statuses["child"] == "failed"
|
||||||
|
assert "STRIX_LLM" in coordinator.errors["child"]
|
||||||
|
assert coordinator.statuses["root"] == "running"
|
||||||
|
assert coordinator.pending_counts.get("root", 0) > 0
|
||||||
|
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
|
||||||
|
) -> None:
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
await coordinator.register("blocked", "recon", parent_id="root")
|
||||||
|
await coordinator.register("peer_waiter", "recon", parent_id="root")
|
||||||
|
await coordinator.set_status("blocked", "waiting", error="STRIX_LLM guardrail")
|
||||||
|
await coordinator.set_status("peer_waiter", "waiting")
|
||||||
|
|
||||||
|
parked: dict[str, bool] = {}
|
||||||
|
|
||||||
|
async def _fake_start_child_runner(**kwargs: Any) -> None:
|
||||||
|
parked[kwargs["child_id"]] = bool(kwargs["start_parked"])
|
||||||
|
|
||||||
|
monkeypatch.setattr(execution, "_start_child_runner", _fake_start_child_runner)
|
||||||
|
|
||||||
|
await respawn_subagents(
|
||||||
|
coordinator=coordinator,
|
||||||
|
factory=lambda **_kwargs: object(),
|
||||||
|
agents_db_path=tmp_path / "agents.db",
|
||||||
|
sessions_to_close=[],
|
||||||
|
run_config=MagicMock(),
|
||||||
|
max_turns=10,
|
||||||
|
interactive=True,
|
||||||
|
parent_ctx={"agent_id": "root", "parent_id": None},
|
||||||
|
root_id="root",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parked["blocked"] is False
|
||||||
|
assert parked["peer_waiter"] is True
|
||||||
|
|||||||
@@ -272,6 +272,30 @@ def test_make_model_settings_omits_timeout_when_unset() -> None:
|
|||||||
assert settings.extra_args is 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:
|
def test_make_model_settings_timeout_survives_reasoning_resolve() -> None:
|
||||||
# Reasoning is resolved via ModelSettings.resolve(); the timeout in extra_args
|
# Reasoning is resolved via ModelSettings.resolve(); the timeout in extra_args
|
||||||
# must not be dropped when a reasoning override is merged in.
|
# must not be dropped when a reasoning override is merged in.
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""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
|
||||||
@@ -40,6 +40,7 @@ async def test_persistent_rate_limit_stops_gracefully(
|
|||||||
force_required_tool_choice=False,
|
force_required_tool_choice=False,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
prompt_cache=True,
|
prompt_cache=True,
|
||||||
|
extra_headers=None,
|
||||||
),
|
),
|
||||||
runtime=types.SimpleNamespace(max_context_images=3),
|
runtime=types.SimpleNamespace(max_context_images=3),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ def _patch_engine_scaffold(
|
|||||||
force_required_tool_choice=False,
|
force_required_tool_choice=False,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
prompt_cache=True,
|
prompt_cache=True,
|
||||||
|
extra_headers=None,
|
||||||
),
|
),
|
||||||
runtime=types.SimpleNamespace(max_context_images=3),
|
runtime=types.SimpleNamespace(max_context_images=3),
|
||||||
)
|
)
|
||||||
|
|||||||
+125
-2
@@ -4,9 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from strix.core.paths import latest_run_dir, runs_base_dir
|
from strix.core.paths import latest_run_dir, runs_base_dir
|
||||||
from strix.interface.viewer.server import serve
|
from strix.interface.viewer.server import serve
|
||||||
@@ -86,6 +88,75 @@ def test_build_run_state_from_agents_json(tmp_path: Path) -> None:
|
|||||||
assert state["events"] == []
|
assert state["events"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_state_keeps_same_call_id_separate_per_agent(tmp_path: Path) -> None:
|
||||||
|
run_dir = _make_run(tmp_path, "tools", status="completed", end_time=None)
|
||||||
|
agents_db = run_dir / ".state" / "agents.db"
|
||||||
|
rows = [
|
||||||
|
(
|
||||||
|
"root",
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "exec_command_0",
|
||||||
|
"name": "exec_command",
|
||||||
|
"arguments": json.dumps({"cmd": "echo root"}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"root",
|
||||||
|
{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "exec_command_0",
|
||||||
|
"output": json.dumps({"success": True, "output": "root"}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"child",
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "exec_command_0",
|
||||||
|
"name": "exec_command",
|
||||||
|
"arguments": json.dumps({"cmd": "echo child"}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"child",
|
||||||
|
{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "exec_command_0",
|
||||||
|
"output": json.dumps({"success": True, "output": "child"}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
with sqlite3.connect(agents_db) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
create table agent_messages (
|
||||||
|
id integer primary key,
|
||||||
|
session_id text not null,
|
||||||
|
message_data text not null,
|
||||||
|
created_at text not null
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
insert into agent_messages (session_id, message_data, created_at)
|
||||||
|
values (?, ?, '2026-01-01T00:00:00+00:00')
|
||||||
|
""",
|
||||||
|
[(agent_id, json.dumps(message)) for agent_id, message in rows],
|
||||||
|
)
|
||||||
|
|
||||||
|
state = build_run_state(run_dir)
|
||||||
|
tools = [event for event in state["events"] if event["type"] == "tool"]
|
||||||
|
|
||||||
|
assert len(tools) == 2
|
||||||
|
by_agent = {event["agent_id"]: event for event in tools}
|
||||||
|
assert by_agent["root"]["data"]["args"] == {"cmd": "echo root"}
|
||||||
|
assert by_agent["root"]["data"]["result"]["output"] == "root"
|
||||||
|
assert by_agent["child"]["data"]["args"] == {"cmd": "echo child"}
|
||||||
|
assert by_agent["child"]["data"]["result"]["output"] == "child"
|
||||||
|
|
||||||
|
|
||||||
def _get(url: str, *, cookie: str | None = None) -> tuple[int, str, bytes]:
|
def _get(url: str, *, cookie: str | None = None) -> tuple[int, str, bytes]:
|
||||||
headers = {"Cookie": cookie} if cookie else {}
|
headers = {"Cookie": cookie} if cookie else {}
|
||||||
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
|
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
|
||||||
@@ -271,6 +342,11 @@ def _session_cookie(url: str, token: str) -> str:
|
|||||||
return raw.split(";", 1)[0]
|
return raw.split(";", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_name(url: str) -> str:
|
||||||
|
"""The per-server session cookie name, derived from the bound port."""
|
||||||
|
return f"strix_viewer_session_{urlsplit(url).port}"
|
||||||
|
|
||||||
|
|
||||||
def _get_status(url: str, *, cookie: str | None = None) -> int:
|
def _get_status(url: str, *, cookie: str | None = None) -> int:
|
||||||
headers = {"Cookie": cookie} if cookie else {}
|
headers = {"Cookie": cookie} if cookie else {}
|
||||||
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
|
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
|
||||||
@@ -311,7 +387,7 @@ def test_capability_issued_only_for_tokened_bootstrap(
|
|||||||
# Only the correct bootstrap token mints the session cookie.
|
# Only the correct bootstrap token mints the session cookie.
|
||||||
with urllib.request.urlopen(f"{url}/?token={token}") as resp: # noqa: S310 # nosec B310
|
with urllib.request.urlopen(f"{url}/?token={token}") as resp: # noqa: S310 # nosec B310
|
||||||
cookie = str(resp.headers.get("Set-Cookie", ""))
|
cookie = str(resp.headers.get("Set-Cookie", ""))
|
||||||
assert "strix_viewer_session=" in cookie
|
assert f"{_cookie_name(url)}=" in cookie
|
||||||
assert "HttpOnly" in cookie and "SameSite=Strict" in cookie
|
assert "HttpOnly" in cookie and "SameSite=Strict" in cookie
|
||||||
|
|
||||||
# Static assets never carry it.
|
# Static assets never carry it.
|
||||||
@@ -344,7 +420,7 @@ def test_unauthorized_client_cannot_acquire_capability(
|
|||||||
url,
|
url,
|
||||||
"/api/agents/steer",
|
"/api/agents/steer",
|
||||||
{"agent_id": "root", "message": "pwn"},
|
{"agent_id": "root", "message": "pwn"},
|
||||||
cookie="strix_viewer_session=",
|
cookie=f"{_cookie_name(url)}=",
|
||||||
)
|
)
|
||||||
assert status == 403
|
assert status == 403
|
||||||
assert delivered == []
|
assert delivered == []
|
||||||
@@ -541,6 +617,53 @@ def test_runs_list_requires_session_and_verification(
|
|||||||
httpd.server_close()
|
httpd.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_servers_use_distinct_cookies(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Cookies are host-scoped, not port-scoped: two viewers on 127.0.0.1 must
|
||||||
|
not share a cookie slot, and one server's cookie must not pass the other's
|
||||||
|
session gate."""
|
||||||
|
run_a = _make_run(tmp_path / "a", "run-a", status="running", end_time=None)
|
||||||
|
run_b = _make_run(tmp_path / "b", "run-b", status="running", end_time=None)
|
||||||
|
_bundle(tmp_path, monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"strix.interface.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True)
|
||||||
|
|
||||||
|
httpd_a, url_a, token_a = serve(run_a, open_browser=False)
|
||||||
|
httpd_b, url_b, token_b = serve(run_b, open_browser=False)
|
||||||
|
try:
|
||||||
|
cookie_a = _session_cookie(url_a, token_a)
|
||||||
|
cookie_b = _session_cookie(url_b, token_b)
|
||||||
|
|
||||||
|
# The two servers mint differently named cookies, so a browser stores both.
|
||||||
|
assert cookie_a.split("=", 1)[0] == _cookie_name(url_a)
|
||||||
|
assert cookie_b.split("=", 1)[0] == _cookie_name(url_b)
|
||||||
|
assert cookie_a.split("=", 1)[0] != cookie_b.split("=", 1)[0]
|
||||||
|
|
||||||
|
def _status(url: str, cookie: str) -> dict[str, object]:
|
||||||
|
_, _, body = _get(f"{url}/api/auth/status", cookie=cookie)
|
||||||
|
return dict(json.loads(body))
|
||||||
|
|
||||||
|
# Each server honors its own cookie...
|
||||||
|
assert _status(url_a, cookie_a)["verified"] is True
|
||||||
|
assert _status(url_b, cookie_b)["verified"] is True
|
||||||
|
# ...but treats the other server's cookie as session-less.
|
||||||
|
assert _status(url_a, cookie_b)["verified"] is False
|
||||||
|
assert _status(url_b, cookie_a)["verified"] is False
|
||||||
|
# Even both cookies together (what a real browser would send) only
|
||||||
|
# match the token minted by the receiving server.
|
||||||
|
both = f"{cookie_a}; {cookie_b}"
|
||||||
|
assert _status(url_a, both)["verified"] is True
|
||||||
|
assert _status(url_b, both)["verified"] is True
|
||||||
|
finally:
|
||||||
|
httpd_a.shutdown()
|
||||||
|
httpd_a.server_close()
|
||||||
|
httpd_b.shutdown()
|
||||||
|
httpd_b.server_close()
|
||||||
|
|
||||||
|
|
||||||
def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
run_dir = _make_run(tmp_path, "guard", status="completed", end_time="2026-01-01T00:00:00Z")
|
run_dir = _make_run(tmp_path, "guard", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||||
secret = tmp_path / "secret.txt"
|
secret = tmp_path / "secret.txt"
|
||||||
|
|||||||
@@ -2411,7 +2411,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strix-agent"
|
name = "strix-agent"
|
||||||
version = "1.4.0"
|
version = "1.4.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "caido-sdk-client" },
|
{ name = "caido-sdk-client" },
|
||||||
|
|||||||
Reference in New Issue
Block a user