refactor: remove custom llm provider layer
This commit is contained in:
+1
-10
@@ -187,19 +187,13 @@ ignore = [
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Lazy imports inside functions to avoid circular dependency with
|
||||
# strix.telemetry / strix.llm.dedupe / cvss.
|
||||
# strix.telemetry / strix.report.dedupe / cvss.
|
||||
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
||||
"strix/tools/finish/tool.py" = ["PLC0415", "TC002"]
|
||||
"strix/tools/reporting/tool.py" = ["PLC0415", "TC002"]
|
||||
"strix/tools/**/*.py" = [
|
||||
"ARG001", # Unused function argument (tools may have unused args for interface consistency)
|
||||
]
|
||||
# Anthropic cache wrapper inherits from LitellmModel; signature shadows builtin `input`.
|
||||
"strix/llm/anthropic_cache_wrapper.py" = [
|
||||
"A002", # Argument shadows builtin (parent signature uses `input`)
|
||||
"TC002", # Many SDK types are imports-for-annotations only
|
||||
"TC003", # collections.abc.AsyncIterator imported for return type
|
||||
]
|
||||
# Custom Docker subclass duplicates parent body; some imports are for annotations.
|
||||
# Backend factories import their backend's deps lazily so deployments
|
||||
# that pick a different backend don't need every backend's libs installed.
|
||||
@@ -208,9 +202,6 @@ ignore = [
|
||||
"TC002", # Manifest, Container imported for annotations
|
||||
"TC003", # uuid imported for annotation
|
||||
]
|
||||
"strix/llm/multi_provider_setup.py" = [
|
||||
"TC002", # Model, ModelProvider imported for annotations
|
||||
]
|
||||
# SDK function-tool wrappers: the SDK calls get_type_hints() at registration
|
||||
# time to derive the JSON schema, which evaluates annotations at runtime —
|
||||
# so RunContextWrapper / Tool / TResponseInputItem must be imported eagerly,
|
||||
|
||||
+3
-5
@@ -121,15 +121,13 @@ hiddenimports = [
|
||||
'strix.agents',
|
||||
'strix.agents.factory',
|
||||
'strix.agents.prompt',
|
||||
'strix.llm',
|
||||
'strix.llm.anthropic_cache_wrapper',
|
||||
'strix.llm.dedupe',
|
||||
'strix.llm.multi_provider_setup',
|
||||
'strix.llm.retry',
|
||||
'strix.config.models',
|
||||
'strix.orchestration',
|
||||
'strix.orchestration.coordinator',
|
||||
'strix.orchestration.runner',
|
||||
'strix.orchestration.utils',
|
||||
'strix.report',
|
||||
'strix.report.dedupe',
|
||||
'strix.runtime',
|
||||
'strix.runtime.backends',
|
||||
'strix.runtime.caido_bootstrap',
|
||||
|
||||
@@ -242,7 +242,7 @@ def build_strix_agent(
|
||||
# looping through think/list_todos forever.
|
||||
reset_tool_choice=interactive,
|
||||
# model=None so ``RunConfig.model`` drives provider selection
|
||||
# via :func:`build_multi_provider` rather than the SDK's default.
|
||||
# through the SDK's default MultiProvider.
|
||||
model=None,
|
||||
capabilities=[Filesystem(), Shell()],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""SDK model configuration helpers.
|
||||
|
||||
This module is intentionally not a provider abstraction. Strix accepts
|
||||
friendly model names at the config boundary, normalizes them to the
|
||||
OpenAI Agents SDK's native model ids, then lets the SDK's default
|
||||
``MultiProvider`` do the actual routing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from agents import set_default_openai_api, set_default_openai_key
|
||||
from agents.retry import (
|
||||
ModelRetryBackoffSettings,
|
||||
ModelRetrySettings,
|
||||
retry_policies,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from strix.config.settings import Settings
|
||||
|
||||
|
||||
_SDK_PREFIXES = {"any-llm", "litellm", "openai"}
|
||||
|
||||
|
||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
max_retries=5,
|
||||
backoff=ModelRetryBackoffSettings(
|
||||
initial_delay=2.0,
|
||||
max_delay=90.0,
|
||||
multiplier=2.0,
|
||||
jitter=False,
|
||||
),
|
||||
policy=retry_policies.any(
|
||||
retry_policies.provider_suggested(),
|
||||
retry_policies.network_error(),
|
||||
retry_policies.http_status((429, 500, 502, 503, 504)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||
"""Apply Strix config to SDK-native defaults.
|
||||
|
||||
OpenAI-compatible base URLs are handled by the SDK OpenAI provider.
|
||||
Non-OpenAI providers should use the SDK's native ``litellm/`` or
|
||||
``any-llm/`` routing, produced by :func:`normalize_model_name`.
|
||||
"""
|
||||
llm = settings.llm
|
||||
_configure_litellm_compatibility()
|
||||
if llm.api_key:
|
||||
set_default_openai_key(llm.api_key, use_for_tracing=False)
|
||||
_configure_litellm_default("api_key", llm.api_key)
|
||||
if llm.api_base:
|
||||
os.environ["OPENAI_BASE_URL"] = llm.api_base
|
||||
_configure_litellm_default("api_base", llm.api_base)
|
||||
set_default_openai_api("chat_completions")
|
||||
else:
|
||||
set_default_openai_api("responses")
|
||||
|
||||
|
||||
def _configure_litellm_compatibility() -> None:
|
||||
"""Match the permissive LiteLLM behavior used by the pre-SDK harness."""
|
||||
import litellm
|
||||
|
||||
litellm.drop_params = True
|
||||
litellm.modify_params = True
|
||||
|
||||
|
||||
def _configure_litellm_default(name: str, value: str) -> None:
|
||||
"""Set LiteLLM's module-level defaults without adding a provider wrapper."""
|
||||
import litellm
|
||||
|
||||
setattr(litellm, name, value)
|
||||
|
||||
|
||||
def normalize_model_name(model_name: str) -> str:
|
||||
"""Normalize friendly Strix model names to SDK-native model ids."""
|
||||
model = model_name.strip()
|
||||
if not model:
|
||||
return model
|
||||
|
||||
if "/" in model:
|
||||
prefix = model.split("/", 1)[0].lower()
|
||||
if prefix in _SDK_PREFIXES:
|
||||
return model
|
||||
return f"litellm/{model}"
|
||||
|
||||
lower = model.lower()
|
||||
if lower.startswith("claude"):
|
||||
return f"litellm/anthropic/{model}"
|
||||
if lower.startswith("gemini"):
|
||||
return f"litellm/gemini/{model}"
|
||||
|
||||
return model
|
||||
@@ -10,8 +10,8 @@ Bool fields auto-parse ``"0"``/``"false"``/``"no"``/``"off"`` as falsy
|
||||
and any other non-empty string as truthy. Int fields auto-coerce from
|
||||
string env. The ``api_base`` field walks an alias chain so users can
|
||||
point at any OpenAI-compatible endpoint via whichever env name they
|
||||
prefer (``LLM_API_BASE`` / ``OPENAI_API_BASE`` / ``LITELLM_BASE_URL`` /
|
||||
``OLLAMA_API_BASE``).
|
||||
prefer (``LLM_API_BASE`` / ``OPENAI_API_BASE`` / ``OPENAI_BASE_URL`` /
|
||||
``LITELLM_BASE_URL`` / ``OLLAMA_API_BASE``).
|
||||
|
||||
Each sub-model is a :class:`BaseSettings` so it reads env independently
|
||||
— the alternative (one mega-BaseSettings with flat fields) would lose
|
||||
@@ -41,12 +41,16 @@ class LlmSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
model: str | None = Field(default=None, alias="STRIX_LLM")
|
||||
api_key: str | None = Field(default=None, alias="LLM_API_KEY")
|
||||
api_key: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"),
|
||||
)
|
||||
api_base: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices(
|
||||
"LLM_API_BASE",
|
||||
"OPENAI_API_BASE",
|
||||
"OPENAI_BASE_URL",
|
||||
"LITELLM_BASE_URL",
|
||||
"OLLAMA_API_BASE",
|
||||
),
|
||||
|
||||
+34
-26
@@ -9,15 +9,21 @@ import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import litellm
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.interface import ModelTracing
|
||||
from agents.models.multi_provider import MultiProvider
|
||||
from docker.errors import DockerException
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import apply_config_override, load_settings, persist_current
|
||||
from strix.config import (
|
||||
apply_config_override,
|
||||
load_settings,
|
||||
persist_current,
|
||||
)
|
||||
from strix.config.models import configure_sdk_model_defaults, normalize_model_name
|
||||
from strix.interface.cli import run_cli
|
||||
from strix.interface.tui import run_tui
|
||||
from strix.interface.utils import (
|
||||
@@ -34,9 +40,9 @@ from strix.interface.utils import (
|
||||
resolve_diff_scope_context,
|
||||
rewrite_localhost_targets,
|
||||
validate_config_file,
|
||||
validate_llm_response,
|
||||
)
|
||||
from strix.telemetry import posthog
|
||||
from strix.telemetry.logging import configure_dependency_logging
|
||||
from strix.telemetry.scan_store import get_global_scan_store
|
||||
|
||||
|
||||
@@ -97,7 +103,7 @@ def validate_environment() -> None:
|
||||
error_text.append("• ", style="white")
|
||||
error_text.append("STRIX_LLM", style="bold cyan")
|
||||
error_text.append(
|
||||
" - Model name to use with litellm (e.g., 'openai/gpt-5.4')\n",
|
||||
" - Model name to use (e.g., 'gpt-5.4' or 'claude-sonnet-4-6')\n",
|
||||
style="white",
|
||||
)
|
||||
|
||||
@@ -136,7 +142,7 @@ def validate_environment() -> None:
|
||||
)
|
||||
|
||||
error_text.append("\nExample setup:\n", style="white")
|
||||
error_text.append("export STRIX_LLM='openai/gpt-5.4'\n", style="dim white")
|
||||
error_text.append("export STRIX_LLM='gpt-5.4'\n", style="dim white")
|
||||
|
||||
if missing_optional_vars:
|
||||
for var in missing_optional_vars:
|
||||
@@ -210,27 +216,27 @@ async def warm_up_llm() -> None:
|
||||
logger.info("Warming up LLM connection")
|
||||
|
||||
try:
|
||||
llm = load_settings().llm
|
||||
settings = load_settings()
|
||||
configure_sdk_model_defaults(settings)
|
||||
llm = settings.llm
|
||||
|
||||
test_messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Reply with just 'OK'."},
|
||||
]
|
||||
|
||||
completion_kwargs: dict[str, Any] = {
|
||||
"model": llm.model,
|
||||
"messages": test_messages,
|
||||
"timeout": llm.timeout,
|
||||
}
|
||||
if llm.api_key:
|
||||
completion_kwargs["api_key"] = llm.api_key
|
||||
if llm.api_base:
|
||||
completion_kwargs["api_base"] = llm.api_base
|
||||
|
||||
response = litellm.completion(**completion_kwargs)
|
||||
|
||||
validate_llm_response(response)
|
||||
logger.info("LLM warm-up succeeded for model %s", llm.model)
|
||||
model = MultiProvider().get_model(normalize_model_name(llm.model or ""))
|
||||
await asyncio.wait_for(
|
||||
model.get_response(
|
||||
system_instructions="You are a helpful assistant.",
|
||||
input="Reply with just 'OK'.",
|
||||
model_settings=ModelSettings(),
|
||||
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 model %s", normalize_model_name(llm.model or ""))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("LLM warm-up failed")
|
||||
@@ -671,6 +677,8 @@ def pull_docker_image() -> None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
configure_dependency_logging()
|
||||
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
|
||||
@@ -1320,12 +1320,6 @@ def process_pull_line(
|
||||
return last_update
|
||||
|
||||
|
||||
# LLM utilities
|
||||
def validate_llm_response(response: Any) -> None:
|
||||
if not response or not response.choices or not response.choices[0].message.content:
|
||||
raise RuntimeError("Invalid response from LLM")
|
||||
|
||||
|
||||
def validate_config_file(config_path: str) -> Path:
|
||||
console = Console()
|
||||
path = Path(config_path)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""LLM package — model provider, prompt-cache wrapper, session, dedup helper.
|
||||
|
||||
Side effects on import:
|
||||
|
||||
- Quiet litellm's debug logger (it spams ``logging.DEBUG`` on every
|
||||
request). The SDK's MultiProvider routes through litellm under the
|
||||
hood, and the debug stream pollutes the run-directory event log.
|
||||
- Quiet asyncio's RuntimeWarning + drop its log propagation; some
|
||||
litellm async paths emit benign cleanup warnings.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
import litellm
|
||||
|
||||
|
||||
litellm._logging._disable_debugging() # type: ignore[no-untyped-call]
|
||||
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
|
||||
logging.getLogger("asyncio").propagate = False
|
||||
warnings.filterwarnings("ignore", category=RuntimeWarning, module="asyncio")
|
||||
@@ -1,135 +0,0 @@
|
||||
"""``AnthropicCachingLitellmModel`` — inject ``cache_control`` on the system message.
|
||||
|
||||
``ModelSettings.extra_body`` lands fields at the request top level,
|
||||
which Anthropic ignores. Anthropic only honors ``cache_control`` when
|
||||
it is on the message itself, so we patch the input list before
|
||||
delegating to the parent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from agents.agent_output import AgentOutputSchemaBase
|
||||
from agents.extensions.models.litellm_model import LitellmModel
|
||||
from agents.handoffs import Handoff
|
||||
from agents.items import ModelResponse, TResponseInputItem, TResponseStreamEvent
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.interface import ModelTracing
|
||||
from agents.tool import Tool
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnthropicCachingLitellmModel(LitellmModel):
|
||||
"""LitellmModel that injects ``cache_control: {"type": "ephemeral"}`` on the
|
||||
system message for Anthropic models. Other providers pass through unchanged.
|
||||
|
||||
Detection: case-insensitive substring match on ``"anthropic/"`` or
|
||||
``"claude"`` against the model name.
|
||||
"""
|
||||
|
||||
def _is_anthropic(self) -> bool:
|
||||
m = (self.model or "").lower()
|
||||
return "anthropic/" in m or "claude" in m
|
||||
|
||||
def _patch(
|
||||
self,
|
||||
items: list[TResponseInputItem],
|
||||
) -> list[TResponseInputItem]:
|
||||
"""Return a copy of ``items`` with cache_control on the system message.
|
||||
|
||||
Returns the input list unchanged for non-Anthropic models. For
|
||||
Anthropic, the first ``role: system`` item has its content rewritten
|
||||
from a string to a list-of-blocks with ``cache_control`` attached.
|
||||
"""
|
||||
if not self._is_anthropic():
|
||||
return items
|
||||
out: list[TResponseInputItem] = []
|
||||
patched_count = 0
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("role") == "system":
|
||||
content = item.get("content")
|
||||
if isinstance(content, str):
|
||||
new_item = {
|
||||
**item,
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": content,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
],
|
||||
}
|
||||
out.append(new_item) # type: ignore[arg-type]
|
||||
patched_count += 1
|
||||
continue
|
||||
out.append(item)
|
||||
if patched_count:
|
||||
logger.debug(
|
||||
"Anthropic cache_control injected on %d system message(s) for %s",
|
||||
patched_count,
|
||||
self.model,
|
||||
)
|
||||
return out
|
||||
|
||||
async def get_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem],
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
previous_response_id: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
prompt: Any | None = None,
|
||||
) -> ModelResponse:
|
||||
patched = self._patch(input if isinstance(input, list) else [])
|
||||
# If input was a string, patching is a no-op; pass straight through.
|
||||
effective: str | list[TResponseInputItem] = patched if isinstance(input, list) else input
|
||||
return await super().get_response(
|
||||
system_instructions,
|
||||
effective,
|
||||
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],
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
previous_response_id: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
prompt: Any | None = None,
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
patched = self._patch(input if isinstance(input, list) else [])
|
||||
effective: str | list[TResponseInputItem] = patched if isinstance(input, list) else input
|
||||
async for event in super().stream_response(
|
||||
system_instructions,
|
||||
effective,
|
||||
model_settings,
|
||||
tools,
|
||||
output_schema,
|
||||
handoffs,
|
||||
tracing,
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
):
|
||||
yield event
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Multi-provider routing setup.
|
||||
|
||||
Wraps the SDK's :class:`MultiProvider` and threads Strix's
|
||||
``LLM_API_KEY`` / ``LLM_API_BASE`` into the underlying provider chain.
|
||||
|
||||
Routing:
|
||||
|
||||
- ``anthropic/<model>`` → :class:`AnthropicCachingLitellmModel` so
|
||||
prompt caching kicks in (we inject ``cache_control`` on the system
|
||||
message before the litellm call).
|
||||
- ``openai/<model>`` (and bare model names) → SDK-native
|
||||
:class:`OpenAIProvider`, instantiated with our settings credentials so
|
||||
``LLM_API_KEY`` works without forcing the user to also export
|
||||
``OPENAI_API_KEY``. Keeps the Responses API as the default transport
|
||||
for genuine OpenAI usage.
|
||||
- Every other prefix (``litellm/...``, ``any-llm/...``, …) falls through
|
||||
to whatever the SDK does natively.
|
||||
|
||||
Real-OpenAI vs OpenAI-compatible differentiation is by
|
||||
``Settings.llm.api_base`` presence. If the user pointed at a non-default
|
||||
base URL they're almost certainly on an OpenAI-compatible endpoint that
|
||||
doesn't speak the Responses API, so we flip ``openai_use_responses=False``
|
||||
to make the inner provider use chat-completions transport instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from agents.exceptions import UserError
|
||||
from agents.models.interface import Model, ModelProvider
|
||||
from agents.models.multi_provider import MultiProvider, MultiProviderMap
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _AnthropicCachingProvider(ModelProvider):
|
||||
"""Routes ``anthropic/<model>`` aliases through
|
||||
:class:`AnthropicCachingLitellmModel`.
|
||||
|
||||
The SDK's ``MultiProvider`` strips the matched prefix before calling
|
||||
``get_model``, so we receive bare ``"<model>"`` (e.g.
|
||||
``"claude-sonnet-4-6"``) and re-prefix with ``anthropic/`` so litellm
|
||||
routes to the Anthropic API.
|
||||
"""
|
||||
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
if not model_name:
|
||||
raise UserError(
|
||||
"Anthropic provider requires a non-empty model name (e.g. 'claude-sonnet-4-6').",
|
||||
)
|
||||
full = model_name if model_name.startswith("anthropic/") else f"anthropic/{model_name}"
|
||||
logger.debug("Anthropic provider: building cached model for %s", full)
|
||||
return AnthropicCachingLitellmModel(model=full)
|
||||
|
||||
|
||||
def build_multi_provider() -> MultiProvider:
|
||||
"""Build the configured MultiProvider.
|
||||
|
||||
Registers the ``anthropic/`` route through our caching wrapper and
|
||||
threads ``Settings.llm`` credentials into the SDK-native
|
||||
:class:`OpenAIProvider` so ``openai/<model>`` works with our single
|
||||
``LLM_API_KEY`` env var. ``Settings.llm.api_base`` (when set) flips
|
||||
the OpenAI provider to chat-completions transport — the de-facto
|
||||
signal that the user is hitting an OpenAI-compatible endpoint that
|
||||
doesn't implement the Responses API.
|
||||
"""
|
||||
pmap = MultiProviderMap() # type: ignore[no-untyped-call]
|
||||
pmap.add_provider("anthropic", _AnthropicCachingProvider())
|
||||
|
||||
llm = load_settings().llm
|
||||
use_responses = llm.api_base is None # default endpoint → real OpenAI
|
||||
logger.debug(
|
||||
"MultiProvider built with anthropic/ cached + openai/ native "
|
||||
"(api_key=%s, base_url=%s, use_responses=%s)",
|
||||
"set" if llm.api_key else "unset",
|
||||
llm.api_base or "default",
|
||||
use_responses,
|
||||
)
|
||||
return MultiProvider(
|
||||
provider_map=pmap,
|
||||
openai_api_key=llm.api_key,
|
||||
openai_base_url=llm.api_base,
|
||||
openai_use_responses=use_responses,
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Shared model-retry policy used across every Strix LLM call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agents.retry import (
|
||||
ModelRetryBackoffSettings,
|
||||
ModelRetrySettings,
|
||||
retry_policies,
|
||||
)
|
||||
|
||||
|
||||
# Retry: 5 attempts with ``min(90, 2*2^n)`` backoff. 4xx auth/validation
|
||||
# errors are excluded from the retryable status list — they can't be
|
||||
# fixed by retrying and should fail fast. Used by every ``RunConfig``
|
||||
# Strix builds, plus the dedupe path's one-shot LLM call outside
|
||||
# ``Runner.run``.
|
||||
DEFAULT_RETRY = ModelRetrySettings(
|
||||
max_retries=5,
|
||||
backoff=ModelRetryBackoffSettings(
|
||||
initial_delay=2.0,
|
||||
max_delay=90.0,
|
||||
multiplier=2.0,
|
||||
jitter=False,
|
||||
),
|
||||
policy=retry_policies.any(
|
||||
retry_policies.provider_suggested(),
|
||||
retry_policies.network_error(),
|
||||
retry_policies.http_status((429, 500, 502, 503, 504)),
|
||||
),
|
||||
)
|
||||
@@ -24,7 +24,7 @@ from openai import APIError
|
||||
|
||||
from strix.agents.factory import build_strix_agent, make_child_factory
|
||||
from strix.config import load_settings
|
||||
from strix.llm.multi_provider_setup import build_multi_provider
|
||||
from strix.config.models import configure_sdk_model_defaults, normalize_model_name
|
||||
from strix.orchestration.coordinator import AgentCoordinator, Status
|
||||
from strix.orchestration.utils import (
|
||||
DEFAULT_MAX_TURNS,
|
||||
@@ -470,7 +470,8 @@ async def run_strix_scan(
|
||||
)
|
||||
|
||||
settings = load_settings()
|
||||
resolved_model = model or settings.llm.model
|
||||
configure_sdk_model_defaults(settings)
|
||||
resolved_model = normalize_model_name(model or settings.llm.model or "")
|
||||
if not resolved_model:
|
||||
raise RuntimeError(
|
||||
"No LLM model configured. Set STRIX_LLM env or pass model= to run_strix_scan().",
|
||||
@@ -540,7 +541,6 @@ async def run_strix_scan(
|
||||
model_settings = make_model_settings(settings.llm.reasoning_effort)
|
||||
run_config = RunConfig(
|
||||
model=resolved_model,
|
||||
model_provider=build_multi_provider(),
|
||||
model_settings=model_settings,
|
||||
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
|
||||
trace_include_sensitive_data=False,
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, Literal
|
||||
from agents.model_settings import ModelSettings
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from strix.llm.retry import DEFAULT_RETRY
|
||||
from strix.config.models import DEFAULT_MODEL_RETRY
|
||||
|
||||
|
||||
# Default max_turns budget passed to the SDK runner.
|
||||
@@ -111,7 +111,7 @@ def make_model_settings(
|
||||
model_settings = ModelSettings(
|
||||
parallel_tool_calls=False,
|
||||
tool_choice="required",
|
||||
retry=DEFAULT_RETRY,
|
||||
retry=DEFAULT_MODEL_RETRY,
|
||||
)
|
||||
if reasoning_effort is not None:
|
||||
model_settings = model_settings.resolve(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Report/finding helpers."""
|
||||
|
||||
from strix.report.dedupe import check_duplicate
|
||||
|
||||
|
||||
__all__ = ["check_duplicate"]
|
||||
@@ -1,10 +1,4 @@
|
||||
"""LLM-based vulnerability-report deduplication.
|
||||
|
||||
Routes through the same :class:`MultiProvider` (so ``anthropic/...``
|
||||
models pick up :class:`AnthropicCachingLitellmModel`'s cache_control
|
||||
patching) and :data:`DEFAULT_RETRY` policy as the main agent loop —
|
||||
no parallel litellm code path.
|
||||
"""
|
||||
"""SDK-native vulnerability-report deduplication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,11 +8,15 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.interface import ModelTracing
|
||||
from agents.models.multi_provider import MultiProvider
|
||||
from openai.types.responses import ResponseOutputMessage
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.llm.multi_provider_setup import build_multi_provider
|
||||
from strix.llm.retry import DEFAULT_RETRY
|
||||
from strix.config.models import (
|
||||
DEFAULT_MODEL_RETRY,
|
||||
configure_sdk_model_defaults,
|
||||
normalize_model_name,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -145,12 +143,6 @@ def _parse_dedupe_response(content: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _extract_text(response: ModelResponse) -> str:
|
||||
"""Concatenate ``output_text`` fragments across every message item.
|
||||
|
||||
The SDK returns OpenAI Responses-API-shaped output; for a plain
|
||||
chat-completion the assistant message has a list of content parts,
|
||||
each of which carries a ``.text`` attribute we can pull verbatim.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for item in response.output:
|
||||
if not isinstance(item, ResponseOutputMessage):
|
||||
@@ -174,7 +166,8 @@ async def check_duplicate(
|
||||
}
|
||||
|
||||
try:
|
||||
model_name = load_settings().llm.model
|
||||
settings = load_settings()
|
||||
model_name = settings.llm.model
|
||||
if not model_name:
|
||||
return {
|
||||
"is_duplicate": False,
|
||||
@@ -193,11 +186,12 @@ async def check_duplicate(
|
||||
f"Respond with ONLY the JSON object described in the system prompt."
|
||||
)
|
||||
|
||||
model = build_multi_provider().get_model(model_name)
|
||||
configure_sdk_model_defaults(settings)
|
||||
model = MultiProvider().get_model(normalize_model_name(model_name))
|
||||
response = await model.get_response(
|
||||
system_instructions=DEDUPE_SYSTEM_PROMPT,
|
||||
input=user_msg,
|
||||
model_settings=ModelSettings(retry=DEFAULT_RETRY),
|
||||
model_settings=ModelSettings(retry=DEFAULT_MODEL_RETRY),
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path # noqa: TC003 used at runtime by ``setup_scan_logging``
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -81,6 +82,19 @@ _HANDLER_TAG = "_strix_scan_handler"
|
||||
_TRACKED_ROOTS: tuple[str, ...] = ("strix", "openai.agents")
|
||||
|
||||
|
||||
def configure_dependency_logging() -> None:
|
||||
"""Quiet dependency logging/warnings that obscure Strix scan logs."""
|
||||
with contextlib.suppress(Exception):
|
||||
import litellm
|
||||
|
||||
litellm_logging = litellm._logging
|
||||
litellm_logging._disable_debugging() # type: ignore[no-untyped-call]
|
||||
|
||||
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
|
||||
logging.getLogger("asyncio").propagate = False
|
||||
warnings.filterwarnings("ignore", category=RuntimeWarning, module="asyncio")
|
||||
|
||||
|
||||
def setup_scan_logging(run_dir: Path, *, debug: bool | None = None) -> Callable[[], None]:
|
||||
"""Attach scan-scoped handlers; return a teardown callable.
|
||||
|
||||
@@ -97,6 +111,8 @@ def setup_scan_logging(run_dir: Path, *, debug: bool | None = None) -> Callable[
|
||||
call attached. Idempotent — calling twice is a no-op the second
|
||||
time. Safe to call from a ``finally`` block.
|
||||
"""
|
||||
configure_dependency_logging()
|
||||
|
||||
if debug is None:
|
||||
debug = (os.environ.get("STRIX_DEBUG") or "").strip().lower() in {
|
||||
"1",
|
||||
|
||||
@@ -151,7 +151,7 @@ _REQUIRED_FIELDS = {
|
||||
}
|
||||
|
||||
|
||||
async def _do_create( # noqa: PLR0912, PLR0915
|
||||
async def _do_create( # noqa: PLR0912
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
@@ -225,7 +225,7 @@ async def _do_create( # noqa: PLR0912, PLR0915
|
||||
"warning": "Report could not be persisted - scan store unavailable",
|
||||
}
|
||||
|
||||
from strix.llm.dedupe import check_duplicate
|
||||
from strix.report.dedupe import check_duplicate
|
||||
|
||||
existing = scan_store.get_existing_vulnerabilities()
|
||||
candidate = {
|
||||
|
||||
Reference in New Issue
Block a user