Compare commits

..
Author SHA1 Message Date
Ian e011253ebc test(compaction): drop comment restating the test name 2026-10-06 21:33:30 -04:00
Ian 5227960e99 fix(compaction): fall back to reasoning text when the summary has none
A reasoning model can spend the whole summary output budget thinking and return only a reasoning item. _extract_text read only output messages, so compaction logged 'summary returned no content' and the agent failed. Use the reasoning text, with a warning, when there is no output text.
2026-10-06 21:33:30 -04:00
9 changed files with 48 additions and 338 deletions

No files matched your search

+1 -31
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
import contextlib
import inspect
import json
import logging
import os
import time
@@ -43,7 +42,7 @@ from strix.config import codex
from strix.config.loader import load_settings
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input
from strix.config.tool_call_limits import TurnToolCallLimiter
from strix.llm import provider_bans, request_log
from strix.llm import request_log
if TYPE_CHECKING:
@@ -746,11 +745,6 @@ def _install_openrouter_stream_cost_capture() -> None:
class _StrixOpenRouterStreamingHandler(OpenRouterChatCompletionStreamingHandler):
def chunk_parser(self, chunk: dict[str, Any]) -> Any:
# Before parsing: LiteLLM raises on an error chunk and drops its
# top-level ``provider``.
request_log.record_upstream_provider(
chunk.get("provider"), _openrouter_error_type(chunk.get("error"))
)
stream = super().chunk_parser(chunk)
usage = chunk.get("usage")
response_id = chunk.get("id") or getattr(stream, "id", None)
@@ -769,22 +763,12 @@ def _install_openrouter_stream_cost_capture() -> None:
json_mode=json_mode,
)
def get_error_class(self, error_message: str, status_code: int, headers: Any) -> Any:
# A non-2xx reply names the provider in ``error.metadata``.
with contextlib.suppress(Exception):
error = json.loads(error_message)["error"]
request_log.record_upstream_provider(
error["metadata"].get("provider_name"), _openrouter_error_type(error)
)
return super().get_error_class(error_message, status_code, headers)
def transform_response(self, *args: Any, **kwargs: Any) -> Any:
# Non-streamed replies (LLM_DISABLE_STREAMING) skip the chunk parser.
response = super().transform_response(*args, **kwargs)
raw_response = kwargs.get("raw_response", args[1] if len(args) > 1 else None)
with contextlib.suppress(Exception):
body = raw_response.json() # type: ignore[union-attr]
request_log.record_upstream_provider(body.get("provider"))
if body.get("usage"):
record_openrouter_provider(body.get("provider"), body["usage"])
return response
@@ -797,12 +781,6 @@ def _install_openrouter_stream_cost_capture() -> None:
if agent_id and load_settings().llm.openrouter_sticky_sessions:
session_id = _OPENROUTER_SESSION_IDS.setdefault(agent_id, str(uuid.uuid4()))
body.setdefault("session_id", session_id)
model = kwargs.get("model", args[0] if args else "")
if banned := provider_bans.banned_providers(model):
# Copy: strix-pro's hook sets ``provider`` to a dict it caches.
provider = dict(body.get("provider") or {})
provider["ignore"] = sorted({*provider.get("ignore", ()), *banned})
body["provider"] = provider
return body
# LiteLLM's provider-config factory reads litellm.OpenrouterConfig at call
@@ -811,14 +789,6 @@ def _install_openrouter_stream_cost_capture() -> None:
litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc]
def _openrouter_error_type(error: object) -> object:
if isinstance(error, dict):
metadata = error.get("metadata")
if isinstance(metadata, dict):
return metadata.get("error_type")
return None
OPENROUTER_ATTRIBUTION_HEADERS = {
"HTTP-Referer": "https://strix.ai",
"X-Title": "Strix",
-1
View File
@@ -72,7 +72,6 @@ class LlmSettings(BaseSettings):
default=False,
alias="STRIX_OPENROUTER_STICKY_SESSIONS",
)
openrouter_provider_bans: bool = Field(default=True, alias="STRIX_OPENROUTER_PROVIDER_BANS")
disable_streaming: bool = Field(
default=False,
alias="LLM_DISABLE_STREAMING",
+20 -9
View File
@@ -15,7 +15,11 @@ from typing import TYPE_CHECKING, Any
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses import (
ResponseOutputMessage,
ResponseOutputText,
ResponseReasoningItem,
)
from strix.config import load_settings
from strix.config.models import StrixProvider
@@ -288,15 +292,22 @@ def _checkpoint_item(summary: str) -> dict[str, Any]:
def _extract_text(response: ModelResponse) -> str:
parts: list[str] = []
reasoning: 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)
if isinstance(item, ResponseOutputMessage):
parts.extend(
chunk.text
for chunk in item.content
if isinstance(chunk, ResponseOutputText) and chunk.text
)
elif isinstance(item, ResponseReasoningItem):
reasoning.extend(summary.text for summary in item.summary if summary.text)
if parts or not reasoning:
return "".join(parts)
# Reasoning models can spend the whole output budget thinking and return no
# message; their reasoning is a degraded but usable summary.
logger.warning("compaction summary had no output text; using reasoning text")
return "\n".join(reasoning)
async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
-74
View File
@@ -1,74 +0,0 @@
"""Per-run bans on the OpenRouter upstream providers behind a model."""
from __future__ import annotations
import logging
import threading
import httpx
from strix.config.loader import load_settings
logger = logging.getLogger(__name__)
_ENDPOINTS_URL = "https://openrouter.ai/api/v1/models/{model}/endpoints"
_LOCK = threading.Lock()
# OpenRouter reports display names ("InferenceNet"); provider.ignore takes slugs ("inference-net").
_SLUGS: dict[str, dict[str, str]] = {}
_BANNED: dict[str, set[str]] = {}
def _model_id(model: str) -> str:
model = model.strip()
for prefix in ("litellm/", "openrouter/"):
model = model.removeprefix(prefix)
return model
def _provider_slugs(model: str) -> dict[str, str]:
if model not in _SLUGS:
response = httpx.get(_ENDPOINTS_URL.format(model=model), timeout=5)
response.raise_for_status()
slugs: dict[str, str] = {}
for endpoint in response.json()["data"]["endpoints"]:
if not (tag := endpoint.get("tag")):
continue
# A tag is the provider slug plus an optional variant, e.g. "inference-net/fp4".
slug = str(tag).split("/")[0]
slugs[slug] = slug
slugs[str(endpoint.get("provider_name") or slug).lower()] = slug
_SLUGS[model] = slugs
return _SLUGS[model]
def ban_provider(model: str, provider: str, reason: str) -> None:
"""Ban for the rest of the run; ``provider`` may be a display name or slug."""
if not load_settings().llm.openrouter_provider_bans:
return
model = _model_id(model)
try:
slugs = _provider_slugs(model)
slug = slugs[provider.strip().lower()]
except Exception as exc: # noqa: BLE001
logger.warning(
"provider_ban_skipped upstream=%s model=%s reason=%s: %r", provider, model, reason, exc
)
return
with _LOCK:
banned = _BANNED.setdefault(model, set())
if slug in banned:
return
banned.add(slug)
logger.warning(
"provider_banned upstream=%s slug=%s model=%s reason=%s", provider, slug, model, reason
)
if banned >= set(slugs.values()):
banned.clear()
logger.warning("provider_bans_reset model=%s: every provider was banned", model)
def banned_providers(model: str) -> list[str]:
with _LOCK:
return sorted(_BANNED.get(_model_id(model), ()))
+2 -31
View File
@@ -159,33 +159,11 @@ class HttpReply:
status_code: int | None = None
headers: dict[str, str] | None = None
upstream_provider: str | None = None
upstream_error_type: str | None = None
_http_reply: ContextVar[HttpReply | None] = ContextVar("strix_llm_http_reply", default=None)
def record_upstream_provider(provider: object, error_type: object = None) -> None:
"""Remember which provider behind a gateway served the attempt in flight."""
reply = _http_reply.get()
if reply is None:
return
if isinstance(provider, str) and provider:
reply.upstream_provider = provider
if isinstance(error_type, str) and error_type:
reply.upstream_error_type = error_type
def _upstream_details(reply: HttpReply | None) -> list[tuple[str, object]]:
if reply is None:
return []
return [
("upstream_provider", reply.upstream_provider),
("upstream_error_type", reply.upstream_error_type),
]
async def record_http_reply(response: Response) -> None:
"""httpx ``response`` event hook: remember the reply for the attempt in flight.
@@ -794,7 +772,6 @@ def _litellm_success(
("request", _litellm_request_details(kwargs)),
("response", _litellm_response_details(response)),
("litellm", _litellm_hidden_details(hidden, slo)),
*_upstream_details(_http_reply.get()),
),
)
@@ -844,7 +821,6 @@ def _litellm_failure(
("request", _litellm_request_details(kwargs)),
("error", error or None),
("litellm", _litellm_hidden_details(hidden, slo)),
*_upstream_details(_http_reply.get()),
),
)
@@ -918,18 +894,15 @@ def _observe_sdk_shared_http_client() -> None:
def _log_line_sink(event: LlmRequestEvent) -> None:
details = event.details or {}
level = logging.DEBUG if event.outcome == "success" else logging.WARNING
logger.log(
level,
"llm_request route=%s provider=%s upstream=%s upstream_error=%s model=%s host=%s "
"outcome=%s status=%s request_id=%s response_id=%s stream=%s duration_ms=%d ttft_ms=%s "
"llm_request route=%s provider=%s model=%s host=%s outcome=%s status=%s "
"request_id=%s response_id=%s stream=%s duration_ms=%d ttft_ms=%s "
"req_bytes=%s res_bytes=%s finish=%s "
"in=%s out=%s cached=%s cost=%s agent=%s attempt=%d%s",
event.route,
event.provider or "-",
details.get("upstream_provider") or "-",
details.get("upstream_error_type") or "-",
event.model,
event.api_host or "-",
event.outcome,
@@ -1097,7 +1070,6 @@ class RequestLoggingModel(Model):
details=merge_details(
("request", request.details),
("response", _openai_response_details(response, raw_response)),
*_upstream_details(reply),
),
)
status, request_id = _openai_error_fields(exc)
@@ -1117,7 +1089,6 @@ class RequestLoggingModel(Model):
details=merge_details(
("request", request.details),
("error", _exception_details(exc)),
*_upstream_details(reply),
),
)
+25
View File
@@ -6,6 +6,8 @@ from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import pytest
from agents.extensions.models.litellm_model import InternalChatCompletionMessage
from agents.models.chatcmpl_converter import Converter
from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
@@ -242,6 +244,29 @@ async def test_summarize_routes_through_provider_with_settings(
assert settings.max_tokens == 64
@pytest.mark.asyncio
async def test_summarize_falls_back_to_reasoning_when_no_output_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
message = InternalChatCompletionMessage(
role="assistant", content=None, reasoning_content="REASONED SUMMARY"
)
response = SimpleNamespace(output=Converter.message_to_output_items(message))
class FakeModel:
async def get_response(self, **_kwargs: Any) -> Any:
return response
class FakeProvider:
def get_model(self, _model_name: str | None) -> Any:
return FakeModel()
monkeypatch.setattr(compaction, "StrixProvider", FakeProvider)
assert await compaction._summarize("m", "p", 64) == "REASONED SUMMARY"
def test_fit_to_tokens_truncates_oversized_text(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(compaction, "count_tokens", lambda _m, t: len(t))
text = "x" * 10_000
-96
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import uuid
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
@@ -390,98 +389,3 @@ def test_openrouter_request_carries_agent_session_id() -> None:
assert body()["session_id"] == session_id
finally:
request_log.reset_call_context(token)
def test_openrouter_request_ignores_banned_providers() -> None:
_install_openrouter_stream_cost_capture()
config = ProviderConfigManager.get_provider_chat_config(
model="moonshotai/kimi-k3", provider=LlmProviders.OPENROUTER
)
assert config is not None
messages: list[AllMessageValues] = [{"role": "user", "content": "hi"}]
pro_preferences = {"order": ["novita", "relace"], "allow_fallbacks": True, "ignore": ["a"]}
with (
patch(
"litellm.llms.openrouter.chat.transformation.OpenrouterConfig.transform_request",
return_value={"provider": pro_preferences},
),
patch("strix.llm.provider_bans.banned_providers", return_value=["relace"]),
):
body = config.transform_request(
model="moonshotai/kimi-k3",
messages=messages,
optional_params={},
litellm_params={},
headers={},
)
assert body["provider"] == {
"order": ["novita", "relace"],
"allow_fallbacks": True,
"ignore": ["a", "relace"],
}
assert pro_preferences["ignore"] == ["a"]
def _openrouter_config() -> Any:
_install_openrouter_stream_cost_capture()
config = ProviderConfigManager.get_provider_chat_config(
model="z-ai/glm-5.3", provider=LlmProviders.OPENROUTER
)
assert config is not None
return config
def test_openrouter_records_upstream_of_stream_and_mid_stream_error() -> None:
handler = _openrouter_config().get_model_response_iterator(
streaming_response=iter([]), sync_stream=True
)
reply = request_log.HttpReply()
token = request_log._http_reply.set(reply)
try:
handler.chunk_parser(
{
"id": "gen-a",
"created": 1,
"model": "z-ai/glm-5.3",
"provider": "Relace",
"choices": [{"index": 0, "delta": {"content": "x"}}],
}
)
assert reply.upstream_provider == "Relace"
assert reply.upstream_error_type is None
with pytest.raises(Exception, match="incomplete tool call"):
handler.chunk_parser(
{
"id": "gen-a",
"provider": "Relace",
"error": {
"code": 400,
"message": "Generation stopped with an incomplete tool call.",
"metadata": {"error_type": "invalid_request"},
},
}
)
finally:
request_log._http_reply.reset(token)
assert reply.upstream_provider == "Relace"
assert reply.upstream_error_type == "invalid_request"
def test_openrouter_records_upstream_of_pre_stream_error() -> None:
body = {
"error": {
"message": "Provider returned error",
"code": 400,
"metadata": {"raw": "...", "provider_name": "InferenceNet"},
}
}
reply = request_log.HttpReply()
token = request_log._http_reply.set(reply)
try:
_openrouter_config().get_error_class(json.dumps(body), 400, {})
_openrouter_config().get_error_class("<html>502 Bad Gateway</html>", 502, {})
finally:
request_log._http_reply.reset(token)
assert reply.upstream_provider == "InferenceNet"
assert reply.upstream_error_type is None
-21
View File
@@ -394,30 +394,9 @@ def test_log_line_sink_formats_without_content(caplog: pytest.LogCaptureFixture)
assert "request_id=req_line01" in line
assert "status=400" in line
assert "provider=anthropic" in line
assert "upstream=- upstream_error=-" in line
assert "SECRET PROMPT" not in line
def test_log_line_names_upstream_recorded_during_attempt(
caplog: pytest.LogCaptureFixture,
) -> None:
exc = _anthropic_error(400, ANTHROPIC_BLOCK_BODY, {})
token = request_log._http_reply.set(request_log.HttpReply())
try:
request_log.record_upstream_provider("Together", "provider_unavailable")
event = request_log.event_from_litellm(
_anthropic_kwargs(exc), None, None, None, outcome="error"
)
finally:
request_log._http_reply.reset(token)
assert event.details is not None
assert event.details["upstream_provider"] == "Together"
with caplog.at_level(logging.DEBUG, logger="strix.llm.request_log"):
request_log._log_line_sink(event)
line = caplog.records[-1].getMessage()
assert "upstream=Together upstream_error=provider_unavailable" in line
# --------------------------------------------------------------------------- #
# sizes, timing, finish reason, free-form headers and details #
# --------------------------------------------------------------------------- #
-75
View File
@@ -1,75 +0,0 @@
"""Tests for per-run OpenRouter provider bans."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import httpx
import pytest
from strix.llm import provider_bans
if TYPE_CHECKING:
from collections.abc import Iterator
MODEL = "openrouter/z-ai/glm-5.3"
ENDPOINTS = [
{"provider_name": "Relace", "tag": "relace"},
{"provider_name": "InferenceNet", "tag": "inference-net/fp4"},
{"provider_name": "Google", "tag": "google-vertex/us-east5"},
{"provider_name": "Google", "tag": "google-vertex"},
]
@pytest.fixture(autouse=True)
def endpoints() -> Iterator[MagicMock]:
for state in (provider_bans._SLUGS, provider_bans._BANNED):
state.clear()
response = httpx.Response(
200,
json={"data": {"endpoints": ENDPOINTS}},
request=httpx.Request("GET", "https://openrouter.ai"),
)
with patch("strix.llm.provider_bans.httpx.get", return_value=response) as get:
yield get
def test_ban_maps_provider_names_to_slugs(endpoints: MagicMock) -> None:
provider_bans.ban_provider(MODEL, "InferenceNet", "context_length")
provider_bans.ban_provider("z-ai/glm-5.3", "google", "provider_unavailable")
provider_bans.ban_provider(MODEL, "InferenceNet", "context_length")
assert provider_bans.banned_providers(MODEL) == ["google-vertex", "inference-net"]
assert provider_bans.banned_providers("openrouter/other/model") == []
endpoints.assert_called_once_with(
"https://openrouter.ai/api/v1/models/z-ai/glm-5.3/endpoints", timeout=5
)
def test_banning_every_provider_unbans_them_all() -> None:
provider_bans.ban_provider(MODEL, "Relace", "incomplete_tool_call")
provider_bans.ban_provider(MODEL, "InferenceNet", "context_length")
assert provider_bans.banned_providers(MODEL) == ["inference-net", "relace"]
provider_bans.ban_provider(MODEL, "Google", "provider_unavailable")
assert provider_bans.banned_providers(MODEL) == []
def test_ban_is_skipped_for_an_unknown_provider_or_failed_lookup(endpoints: MagicMock) -> None:
provider_bans.ban_provider(MODEL, "Nobody", "provider_unavailable")
endpoints.side_effect = httpx.ConnectError("offline")
provider_bans.ban_provider("openrouter/other/model", "Relace", "x")
assert provider_bans.banned_providers(MODEL) == []
def test_bans_can_be_turned_off(endpoints: MagicMock) -> None:
with patch("strix.llm.provider_bans.load_settings") as settings:
settings.return_value.llm.openrouter_provider_bans = False
provider_bans.ban_provider(MODEL, "Relace", "incomplete_tool_call")
assert provider_bans.banned_providers(MODEL) == []
endpoints.assert_not_called()