diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 5d56c9a1..fe911907 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -35,6 +35,31 @@ Configure Strix using environment variables or a config file. Timeout in seconds for memory compression operations (context summarization). +### Dedicated deduplication model + +Finding deduplication is a cheap, structured classification task. By default it +runs on the main model, but you can route it to a smaller/cheaper model without +affecting the agents that do the actual testing. + + + Model used to judge whether a candidate finding duplicates an existing report. + Falls back to `STRIX_LLM` when unset. + + + + Optional provider key for the deduplication model. + + + + Optional custom API base URL for the deduplication model. Use when the dedupe + model runs on a different endpoint than the main model. + + + + Reasoning effort for the deduplication model. Defaults to the model's own + baseline when unset. + + ## Optional Features diff --git a/pyproject.toml b/pyproject.toml index f51877d4..ba3f0a34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,6 +259,9 @@ ignore = [ # a runtime ``Callable`` annotation on ``vulnerability_found_callback``. "strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"] "strix/report/usage.py" = ["PLC0415"] +# Lazy import of strix.config.models avoids a circular dependency between the +# report pipeline and the config layer. +"strix/report/dedupe.py" = ["PLC0415"] "strix/telemetry/logging.py" = ["PLC0415"] "strix/config/models.py" = ["PLC0415"] # Heavy inference deps (httpx, openai) imported lazily so auth-status checks diff --git a/strix/config/__init__.py b/strix/config/__init__.py index fda68602..6e9dded1 100644 --- a/strix/config/__init__.py +++ b/strix/config/__init__.py @@ -17,6 +17,7 @@ from strix.config.loader import ( persist_current, ) from strix.config.settings import ( + DedupeSettings, IntegrationSettings, LlmSettings, RuntimeSettings, @@ -26,6 +27,7 @@ from strix.config.settings import ( __all__ = [ + "DedupeSettings", "IntegrationSettings", "LlmSettings", "RuntimeSettings", diff --git a/strix/config/settings.py b/strix/config/settings.py index 387e13f3..98d68934 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -43,6 +43,18 @@ class LlmSettings(BaseSettings): timeout: int = Field(default=300, alias="LLM_TIMEOUT") +class DedupeSettings(BaseSettings): + model_config = _BASE_CONFIG + + model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL") + reasoning_effort: ReasoningEffort | None = Field( + default=None, + alias="STRIX_DEDUPE_REASONING_EFFORT", + ) + api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY") + api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE") + + class RuntimeSettings(BaseSettings): model_config = _BASE_CONFIG @@ -85,6 +97,7 @@ class Settings(BaseSettings): model_config = _BASE_CONFIG llm: LlmSettings = Field(default_factory=LlmSettings) + dedupe: DedupeSettings = Field(default_factory=DedupeSettings) runtime: RuntimeSettings = Field(default_factory=RuntimeSettings) telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings) integrations: IntegrationSettings = Field(default_factory=IntegrationSettings) diff --git a/strix/interface/main.py b/strix/interface/main.py index 39b48041..8ca10009 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -394,6 +394,33 @@ 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 + + dedupe_model = settings.dedupe.model.strip() + raw_model = dedupe_model + deduper = StrixProvider().get_model(dedupe_model) + # Match the runtime path: send the dedupe key/endpoint per call so a + # separate-provider dedupe model authenticates during warm-up too. + deduper_extra = _dedupe_extra_args(settings.dedupe) + deduper_settings = ModelSettings(extra_args=deduper_extra or None) + await asyncio.wait_for( + deduper.get_response( + system_instructions="You are a helpful assistant.", + input="Reply with just 'OK'.", + model_settings=deduper_settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ), + timeout=llm.timeout, + ) + logger.info("LLM warm-up succeeded for dedupe model %s", dedupe_model) + except Exception as e: logger.exception("LLM warm-up failed") error_text = Text() diff --git a/strix/report/dedupe.py b/strix/report/dedupe.py index c2970d95..b47ed5a4 100644 --- a/strix/report/dedupe.py +++ b/strix/report/dedupe.py @@ -13,20 +13,55 @@ from openai.types.responses import ResponseOutputMessage from strix.config import load_settings from strix.config.models import ( - DEFAULT_MODEL_RETRY, StrixProvider, configure_sdk_model_defaults, - request_timeout_extra_args, ) +from strix.core.inputs import make_model_settings from strix.report.state import get_global_report_state if TYPE_CHECKING: from agents.items import ModelResponse + from strix.config.settings import DedupeSettings + 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: + settings = make_model_settings( + dedupe.reasoning_effort, + model_name=model_name, + force_required_tool_choice=False, + request_timeout=request_timeout, + ) + extra = _dedupe_extra_args(dedupe) + if extra: + settings = settings.resolve(ModelSettings(extra_args=extra)) + return settings + DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge. Your task is to determine if a candidate vulnerability report describes the SAME vulnerability as any existing report. @@ -286,13 +321,14 @@ async def check_duplicate( try: settings = load_settings() - model_name = settings.llm.model + dedupe = settings.dedupe + model_name = (dedupe.model or "").strip() or settings.llm.model if not model_name: return { "is_duplicate": False, "duplicate_id": "", "confidence": 0.0, - "reason": "STRIX_LLM not configured; skipping dedupe check", + "reason": "No LLM model configured; skipping dedupe check", } candidate_cleaned = _prepare_report_for_comparison(candidate) @@ -311,10 +347,8 @@ async def check_duplicate( response = await model.get_response( system_instructions=DEDUPE_SYSTEM_PROMPT, input=user_msg, - model_settings=ModelSettings( - retry=DEFAULT_MODEL_RETRY, - include_usage=True, - extra_args=request_timeout_extra_args(settings.llm.timeout), + model_settings=_dedupe_model_settings( + dedupe, resolved_model, settings.llm.timeout ), tools=[], output_schema=None, diff --git a/tests/test_dedupe_model.py b/tests/test_dedupe_model.py new file mode 100644 index 00000000..0a0f7654 --- /dev/null +++ b/tests/test_dedupe_model.py @@ -0,0 +1,94 @@ +"""Tests for the dedicated deduplication model configuration.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from strix.config import loader +from strix.config.settings import DedupeSettings +from strix.report.dedupe import _dedupe_model_settings + + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def test_dedupe_key_sent_per_call_not_via_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" + + +def test_dedupe_settings_omit_api_key_when_unset() -> None: + dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap") + 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: + 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" + + +def test_dedupe_defaults_are_empty() -> None: + settings = DedupeSettings() + assert settings.model is None + assert settings.reasoning_effort is None + assert settings.api_key is None + + +def test_dedupe_model_read_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIX_DEDUPE_MODEL", "deepseek/deepseek-v4-flash") + monkeypatch.setenv("STRIX_DEDUPE_REASONING_EFFORT", "low") + + settings = DedupeSettings() + + assert settings.model == "deepseek/deepseek-v4-flash" + assert settings.reasoning_effort == "low" + + +def test_config_file_loads_dedupe_model( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + for key in ("STRIX_LLM", "STRIX_DEDUPE_MODEL", "STRIX_DEDUPE_REASONING_EFFORT"): + monkeypatch.delenv(key, raising=False) + path = tmp_path / "config.json" + path.write_text( + json.dumps( + { + "env": { + "STRIX_LLM": "openai/root", + "STRIX_DEDUPE_MODEL": "deepseek/cheap", + "STRIX_DEDUPE_REASONING_EFFORT": "minimal", + } + } + ), + encoding="utf-8", + ) + loader._cached = None + loader._override = path + try: + settings = loader.load_settings() + finally: + loader._cached = None + loader._override = None + + assert settings.dedupe.model == "deepseek/cheap" + assert settings.dedupe.reasoning_effort == "minimal" + # Main model stays independent of the dedupe override. + assert settings.llm.model == "openai/root"