Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	strix/interface/main.py
#	strix/report/dedupe.py
This commit is contained in:
bearsyankees 2026-08-28 12:58:35 -04:00
commit bda89717d5
7 changed files with 185 additions and 60 deletions

View file

@ -18,7 +18,7 @@ from agents import (
)
from agents.model_settings import ModelSettings
from agents.models.fake_id import FAKE_RESPONSES_ID
from agents.models.interface import Model
from agents.models.interface import Model, ModelProvider
from agents.models.multi_provider import MultiProvider
from agents.models.openai_responses import OpenAIResponsesModel
from agents.retry import (
@ -48,7 +48,7 @@ if TYPE_CHECKING:
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.models.interface import ModelTracing
from agents.retry import ModelRetryAdvice, ModelRetryAdviceRequest
from agents.tool import Tool
from agents.usage import Usage
@ -445,12 +445,61 @@ def _response_usage(usage: Usage | None) -> ResponseUsage | None:
)
class _CredentialedLitellmProvider(ModelProvider):
"""LiteLLM route bound to one endpoint's credentials.
``LitellmProvider`` reads them from the process-wide LiteLLM globals, which
belong to the main model; a secondary endpoint needs its own.
"""
def __init__(self, api_key: str | None, base_url: str | None) -> None:
self._api_key = api_key
self._base_url = base_url
def get_model(self, model_name: str | None) -> Model:
from agents.extensions.models.litellm_model import LitellmModel
from agents.models.default_models import get_default_model
return LitellmModel(
model=model_name or get_default_model(),
api_key=self._api_key,
base_url=self._base_url,
)
class StrixProvider(MultiProvider):
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
so users type ``deepseek/deepseek-chat`` rather than
``litellm/deepseek/deepseek-chat``.
``api_key``/``base_url`` bind every route this provider resolves to one
endpoint, for a secondary model (the dedupe judge) whose endpoint differs
from the main model's process-wide defaults.
"""
def __init__(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
**kwargs: Any,
) -> None:
super().__init__(
openai_api_key=api_key,
openai_base_url=base_url,
# A custom endpoint is OpenAI-compatible, i.e. chat completions; the
# global default is the main model's and may say otherwise.
openai_use_responses=False if base_url else None,
**kwargs,
)
self._override_api_key = api_key
self._override_base_url = base_url
def _create_fallback_provider(self, prefix: str) -> ModelProvider:
if prefix == "litellm" and (self._override_api_key or self._override_base_url):
return _CredentialedLitellmProvider(self._override_api_key, self._override_base_url)
return super()._create_fallback_provider(prefix)
def _resolve_prefixed_model(
self,
*,
@ -864,6 +913,22 @@ def is_claude_model(model_name: str) -> bool:
return "claude" in (model_name or "").strip().lower()
def routes_through_litellm(model_name: str | None) -> bool:
"""Whether :class:`StrixProvider` sends this model through LiteLLM.
Bare names and the ``openai/``/``any-llm/`` prefixes are served by the SDK's
own clients, which raise ``TypeError`` on request fields they do not know,
so LiteLLM-only fields must not be attached there. A bare ``claude-...``
name is exactly that case: an ``LLM_API_BASE`` pointing at an
OpenAI-compatible gateway in front of Claude.
"""
name = (model_name or "").strip()
if not name or codex.subscription_model(name):
return False
prefix, _, rest = name.partition("/")
return bool(rest) and prefix.lower() not in {"openai", "any-llm"}
def is_bedrock_route(model_name: str) -> bool:
name = (model_name or "").strip().lower()
return name.startswith("bedrock/") or "anthropic." in name

View file

@ -18,6 +18,7 @@ from strix.config.models import (
is_openrouter_model,
model_supports_reasoning,
request_timeout_extra_args,
routes_through_litellm,
)
from strix.core.sessions import scrub_images_from_items
@ -267,7 +268,7 @@ def make_model_settings(
and model_supports_reasoning(model_name)
):
model_settings = model_settings.resolve(
_reasoning_settings(reasoning_effort, model_settings.extra_args),
_reasoning_settings(reasoning_effort),
)
if force_required_tool_choice and _accepts_required_tool_choice(model_name):
model_settings = model_settings.resolve(ModelSettings(tool_choice="required"))
@ -293,20 +294,19 @@ def _request_headers(
return headers or None
def _reasoning_settings(
effort: ReasoningEffort,
extra_args: dict[str, Any] | None,
) -> ModelSettings:
def _reasoning_settings(effort: ReasoningEffort) -> ModelSettings:
"""``max`` is not in the OpenAI SDK's ``Reasoning.effort`` enum, so send it as
a raw body field instead also keeping it clear of LiteLLM's DeepSeek mapping,
which collapses every ``reasoning_effort`` level to plain thinking-enabled.
Providers that don't support ``max`` reject the request.
It goes in ``extra_body``, the field every model implementation forwards as the
request's ``extra_body``; the same value under ``extra_args`` collides with that
keyword and raises before a request is ever sent.
"""
if effort != "max":
return ModelSettings(reasoning=Reasoning(effort=effort))
return ModelSettings(
extra_args={**(extra_args or {}), "extra_body": {"reasoning_effort": "max"}},
)
return ModelSettings(extra_body={"reasoning_effort": "max"})
def _prompt_cache_extra_args(model_name: str) -> dict[str, Any] | None:
@ -317,8 +317,13 @@ def _prompt_cache_extra_args(model_name: str) -> dict[str, Any] | None:
it elsewhere it leaks onto the wire and native Anthropic 400s). Unmapped
Bedrock models get no points at all: Bedrock rejects the passed-through
field outright.
The field is LiteLLM's own, consumed by its transform, so it only goes to
routes LiteLLM serves. A bare ``claude-...`` name is served by the SDK's
OpenAI client instead (a gateway in front of Claude), and that client raises
``TypeError`` on request kwargs it does not know.
"""
if not is_claude_model(model_name):
if not is_claude_model(model_name) or not routes_through_litellm(model_name):
return None
if is_bedrock_route(model_name) and not bedrock_route_supports_prompt_caching(model_name):
return None

View file

@ -135,12 +135,10 @@ def _subscription_error_hint(exc: BaseException) -> str | None:
async def warm_up_llm(show_model_warning: bool = True) -> None:
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from strix.config.models import (
RECOMMENDED_MODEL_NAMES,
StrixProvider,
configure_sdk_model_defaults,
is_known_openai_bare_model,
is_recommended_or_frontier_model,
@ -217,12 +215,11 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
if settings.dedupe.model:
from strix.report.dedupe import dedupe_extra_args
from strix.report.dedupe import resolve_dedupe_model
dedupe_model = settings.dedupe.model.strip()
raw_model = dedupe_model
deduper = StrixProvider().get_model(dedupe_model)
deduper_extra = dedupe_extra_args(settings.dedupe)
deduper = resolve_dedupe_model(settings.dedupe, dedupe_model)
# A dedicated dedupe model may route to another provider, which must
# never receive the main endpoint's headers; it has its own
# DEDUPE_LLM_EXTRA_HEADERS.
@ -234,9 +231,6 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
extra_headers=settings.dedupe.extra_headers,
has_tools=False,
)
if deduper_extra:
merged = {**(deduper_settings.extra_args or {}), **deduper_extra}
deduper_settings = deduper_settings.resolve(ModelSettings(extra_args=merged))
await asyncio.wait_for(
deduper.get_response(
system_instructions="You are a helpful assistant.",

View file

@ -7,7 +7,6 @@ import logging
import re
from typing import TYPE_CHECKING, Any
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from openai.types.responses import ResponseOutputMessage
@ -22,6 +21,8 @@ from strix.report.state import get_global_report_state
if TYPE_CHECKING:
from agents.items import ModelResponse
from agents.model_settings import ModelSettings
from agents.models.interface import Model
from strix.config.settings import DedupeSettings
@ -29,30 +30,11 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def dedupe_extra_args(dedupe: DedupeSettings) -> dict[str, str]:
"""Per-call credential + endpoint for the dedupe model.
Provider env vars and the global base URL are process-wide, so a
shared-provider dedupe key or a distinct dedupe endpoint can't be installed
globally without clobbering (or being clobbered by) the main model's
config. Passing them per call keeps the two apart. Only applies when a
dedicated dedupe model is configured.
"""
if not dedupe.model:
return {}
extra: dict[str, str] = {}
if dedupe.api_key and dedupe.api_key.strip():
extra["api_key"] = dedupe.api_key.strip()
if dedupe.api_base and dedupe.api_base.strip():
extra["api_base"] = dedupe.api_base.strip()
return extra
def _dedupe_model_settings(
dedupe: DedupeSettings, model_name: str, request_timeout: float | None
) -> ModelSettings:
llm = load_settings().llm
settings = make_model_settings(
return make_model_settings(
dedupe.reasoning_effort,
model_name=model_name,
force_required_tool_choice=False,
@ -64,10 +46,21 @@ def _dedupe_model_settings(
extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers,
has_tools=False,
)
extra = dedupe_extra_args(dedupe)
if extra:
settings = settings.resolve(ModelSettings(extra_args=extra))
return settings
def resolve_dedupe_model(dedupe: DedupeSettings, model_name: str) -> Model:
"""Resolve the dedupe model, bound to its own endpoint when it has one.
Credentials can't ride on the request: every model implementation already
passes its own ``api_key``/``base_url``, so the same keys in ``extra_args``
collide with them and raise before anything is sent. A provider bound to the
dedupe endpoint keeps it apart from the main model's process-wide defaults.
"""
api_key = (dedupe.api_key or "").strip() if dedupe.model else ""
api_base = (dedupe.api_base or "").strip() if dedupe.model else ""
if not (api_key or api_base):
return StrixProvider().get_model(model_name)
return StrixProvider(api_key=api_key or None, base_url=api_base or None).get_model(model_name)
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
@ -371,7 +364,7 @@ async def check_duplicate(
configure_sdk_model_defaults(settings)
resolved_model = model_name.strip()
model = StrixProvider().get_model(resolved_model)
model = resolve_dedupe_model(dedupe, resolved_model)
response = await model.get_response(
system_instructions=DEDUPE_SYSTEM_PROMPT,
input=user_msg,

View file

@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
from strix.config import loader
from strix.config.settings import DedupeSettings
from strix.report.dedupe import _dedupe_model_settings
from strix.report.dedupe import _dedupe_model_settings, resolve_dedupe_model
if TYPE_CHECKING:
@ -16,32 +16,49 @@ if TYPE_CHECKING:
import pytest
def test_dedupe_key_sent_per_call_not_via_global_env() -> None:
def _unwrap(model: object) -> object:
while hasattr(model, "_inner"):
model = model._inner
return model
def test_dedupe_key_bound_to_model_client_not_global_env() -> None:
dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap", DEDUPE_LLM_API_KEY="dedupe-key")
settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300)
# The key rides on the request, so a shared-provider main key can't clobber
# it (and vice versa) through the global provider env var.
assert (settings.extra_args or {})["api_key"] == "dedupe-key"
model = _unwrap(resolve_dedupe_model(dedupe, "deepseek/cheap"))
# The key is bound to the dedupe model's own client, so a shared-provider
# main key can't clobber it (and vice versa) through the process globals —
# and it never rides on the request, where every model implementation's own
# api_key kwarg would collide with it.
assert model.api_key == "dedupe-key" # type: ignore[attr-defined]
def test_dedupe_settings_omit_api_key_when_unset() -> None:
dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap")
def test_dedupe_settings_carry_no_request_credentials() -> None:
dedupe = DedupeSettings(
STRIX_DEDUPE_MODEL="deepseek/cheap",
DEDUPE_LLM_API_KEY="dedupe-key",
DEDUPE_LLM_API_BASE="https://dedupe.example/v1",
)
settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300)
assert "api_key" not in (settings.extra_args or {})
assert "api_base" not in (settings.extra_args or {})
def test_dedupe_endpoint_sent_per_call() -> None:
def test_dedupe_endpoint_bound_to_model_client() -> None:
dedupe = DedupeSettings(
STRIX_DEDUPE_MODEL="openai/cheap",
DEDUPE_LLM_API_KEY="dedupe-key",
DEDUPE_LLM_API_BASE="https://dedupe.example/v1",
)
settings = _dedupe_model_settings(dedupe, "openai/cheap", 300)
# A distinct dedupe endpoint rides on the request instead of the
# process-wide base URL, so it can't clobber the main model's endpoint.
assert (settings.extra_args or {})["api_base"] == "https://dedupe.example/v1"
assert (settings.extra_args or {})["api_key"] == "dedupe-key"
model = _unwrap(resolve_dedupe_model(dedupe, "openai/cheap"))
client = model._client # type: ignore[attr-defined]
assert client.api_key == "dedupe-key"
assert str(client.base_url).startswith("https://dedupe.example/v1")
def test_dedupe_without_credentials_uses_default_provider() -> None:
dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap")
model = _unwrap(resolve_dedupe_model(dedupe, "deepseek/cheap"))
assert model.api_key is None # type: ignore[attr-defined]
def test_dedicated_dedupe_model_uses_own_headers_not_main() -> None:

View file

@ -90,6 +90,16 @@ def test_make_model_settings_enables_prompt_cache_for_non_bedrock_claude(model_n
]
@pytest.mark.parametrize(
"model_name",
["claude-sonnet-4-5", "openai/claude-sonnet-4-5", "any-llm/anthropic/claude-sonnet-4-5"],
)
def test_no_prompt_cache_for_claude_off_the_litellm_route(model_name: str) -> None:
# These names are served by SDK clients that raise TypeError on LiteLLM-only
# request kwargs — e.g. a gateway in front of Claude reached with a bare name.
assert _cache_points(model_name) is None
def test_tool_config_point_not_leaked_to_non_bedrock_claude() -> None:
# LiteLLM only consumes tool_config on Bedrock; elsewhere it leaks onto the
# wire and native Anthropic 400s.
@ -143,7 +153,8 @@ def test_max_reasoning_effort_sent_as_raw_body_field() -> None:
"max", model_name="deepseek/deepseek-v4-flash", request_timeout=30
)
assert settings.reasoning is None
assert settings.extra_args == {"timeout": 30, "extra_body": {"reasoning_effort": "max"}}
assert settings.extra_args == {"timeout": 30}
assert settings.extra_body == {"reasoning_effort": "max"}
def test_conversation_tail_breakpoint_moves_with_appended_transcript() -> None:

View file

@ -3,12 +3,17 @@
from __future__ import annotations
import pytest
from agents.extensions.models.litellm_model import LitellmModel
from agents.model_settings import ModelSettings
from strix.config.models import (
RECOMMENDED_MODEL_NAMES,
StrixProvider,
_NonStreamingModel,
_TurnGuardModel,
is_recommended_or_frontier_model,
request_timeout_extra_args,
routes_through_litellm,
supports_strict_tool_schemas,
)
@ -112,3 +117,38 @@ def test_claude_routes_reject_strict_tool_schemas(model_name: str) -> None:
)
def test_other_routes_keep_strict_tool_schemas(model_name: str) -> None:
assert supports_strict_tool_schemas(model_name)
@pytest.mark.parametrize(
("model_name", "litellm"),
[
("claude-sonnet-4-5", False),
("openai/claude-sonnet-4-5", False),
("any-llm/anthropic/claude-sonnet-4-5", False),
("anthropic/claude-sonnet-4-5", True),
("litellm/anthropic/claude-sonnet-4-5", True),
("bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0", True),
("ollama/llama3", True),
],
)
def test_routes_through_litellm_matches_the_provider(
monkeypatch: pytest.MonkeyPatch, model_name: str, litellm: bool
) -> None:
"""The helper must agree with what StrixProvider actually builds.
Callers use it to decide whether a LiteLLM-only request field is safe to
attach; on the SDK's own clients such a field raises TypeError mid-turn, so
drift here breaks every request on that route.
"""
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
assert routes_through_litellm(model_name) is litellm
try:
model = StrixProvider().get_model(model_name)
except ImportError:
# any-llm's client is an optional dependency; reaching it at all already
# proves the route is not LiteLLM's.
assert not litellm
return
while isinstance(model, _NonStreamingModel | _TurnGuardModel):
model = model._inner
assert isinstance(model, LitellmModel) is litellm