Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 250fe2cf3e | |||
| 6c99829325 | |||
| 1c9ab993bb | |||
| 04eb03febe | |||
| ac0fef2ed7 | |||
| dcf3155a9a | |||
| 36b374bd1b | |||
| 1a329e8972 | |||
| 143b9e7040 | |||
| 3665a7899f | |||
| 232711be8c | |||
| 712c64f630 | |||
| dee2a03d07 | |||
| 1473fc7336 | |||
| dd1f816f7c | |||
| 9ab70c6d61 | |||
| 13046cc74a | |||
| 1aad460f6e |
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "strix-agent"
|
name = "strix-agent"
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
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"
|
||||||
@@ -219,6 +219,8 @@ ignore = [
|
|||||||
# ReportState carries scan artifact/report fields and
|
# ReportState carries scan artifact/report fields and
|
||||||
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
||||||
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
|
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
|
||||||
|
"strix/report/usage.py" = ["PLC0415"]
|
||||||
|
"strix/config/models.py" = ["PLC0415"]
|
||||||
# Interface utility branches per scope-mode / target-type combination;
|
# Interface utility branches per scope-mode / target-type combination;
|
||||||
# splitting would obscure the decision tree without simplifying it.
|
# splitting would obscure the decision tree without simplifying it.
|
||||||
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
||||||
|
|||||||
@@ -395,7 +395,6 @@ def build_strix_agent(
|
|||||||
instructions=instructions,
|
instructions=instructions,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
tool_use_behavior=_finish_tool_use_behavior,
|
tool_use_behavior=_finish_tool_use_behavior,
|
||||||
reset_tool_choice=interactive,
|
|
||||||
model=None,
|
model=None,
|
||||||
capabilities=[
|
capabilities=[
|
||||||
Filesystem(
|
Filesystem(
|
||||||
|
|||||||
+94
-32
@@ -5,7 +5,8 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from agents import set_default_openai_api, set_default_openai_key
|
from agents import set_default_openai_api, set_default_openai_key, set_tracing_disabled
|
||||||
|
from agents.models.multi_provider import MultiProvider
|
||||||
from agents.retry import (
|
from agents.retry import (
|
||||||
ModelRetryBackoffSettings,
|
ModelRetryBackoffSettings,
|
||||||
ModelRetrySettings,
|
ModelRetrySettings,
|
||||||
@@ -14,10 +15,31 @@ from agents.retry import (
|
|||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from agents.models.interface import ModelProvider
|
||||||
|
|
||||||
from strix.config.settings import Settings
|
from strix.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
_SDK_PREFIXES = {"any-llm", "litellm", "openai"}
|
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``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _resolve_prefixed_model(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
original_model_name: str,
|
||||||
|
prefix: str,
|
||||||
|
stripped_model_name: str | None,
|
||||||
|
) -> tuple[ModelProvider, str | None]:
|
||||||
|
if prefix in {"openai", "litellm", "any-llm"}:
|
||||||
|
return super()._resolve_prefixed_model(
|
||||||
|
original_model_name=original_model_name,
|
||||||
|
prefix=prefix,
|
||||||
|
stripped_model_name=stripped_model_name,
|
||||||
|
)
|
||||||
|
return self._get_fallback_provider("litellm"), original_model_name
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||||
@@ -37,17 +59,14 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
|||||||
|
|
||||||
|
|
||||||
def configure_sdk_model_defaults(settings: Settings) -> None:
|
def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||||
"""Apply Strix config to SDK-native defaults.
|
"""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
|
llm = settings.llm
|
||||||
|
set_tracing_disabled(True)
|
||||||
_configure_litellm_compatibility()
|
_configure_litellm_compatibility()
|
||||||
if llm.api_key:
|
if llm.api_key:
|
||||||
set_default_openai_key(llm.api_key, use_for_tracing=False)
|
set_default_openai_key(llm.api_key, use_for_tracing=False)
|
||||||
_configure_litellm_default("api_key", llm.api_key)
|
_configure_litellm_default("api_key", llm.api_key)
|
||||||
|
_mirror_api_key_to_provider_env(llm.model, llm.api_key)
|
||||||
if llm.api_base:
|
if llm.api_base:
|
||||||
os.environ["OPENAI_BASE_URL"] = llm.api_base
|
os.environ["OPENAI_BASE_URL"] = llm.api_base
|
||||||
_configure_litellm_default("api_base", llm.api_base)
|
_configure_litellm_default("api_base", llm.api_base)
|
||||||
@@ -56,12 +75,50 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
|
|||||||
set_default_openai_api("responses")
|
set_default_openai_api("responses")
|
||||||
|
|
||||||
|
|
||||||
|
def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None:
|
||||||
|
if not model_name:
|
||||||
|
return
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
name = model_name.strip()
|
||||||
|
for prefix in ("litellm/", "any-llm/"):
|
||||||
|
if name.lower().startswith(prefix):
|
||||||
|
name = name[len(prefix) :]
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
report = litellm.validate_environment(model=name.lower())
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return
|
||||||
|
for env_key in report.get("missing_keys") or []:
|
||||||
|
if env_key.endswith("_API_KEY"):
|
||||||
|
os.environ.setdefault(env_key, api_key)
|
||||||
|
|
||||||
|
|
||||||
def _configure_litellm_compatibility() -> None:
|
def _configure_litellm_compatibility() -> None:
|
||||||
"""Enable LiteLLM's permissive param-handling mode."""
|
"""Enable LiteLLM's permissive param handling and disable its callbacks."""
|
||||||
import litellm
|
import litellm
|
||||||
|
|
||||||
litellm.drop_params = True
|
litellm.drop_params = True
|
||||||
litellm.modify_params = True
|
litellm.modify_params = True
|
||||||
|
litellm.turn_off_message_logging = True
|
||||||
|
litellm.disable_streaming_logging = True
|
||||||
|
litellm.suppress_debug_info = True
|
||||||
|
|
||||||
|
_register_litellm_cost_callback()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_litellm_cost_callback() -> None:
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
from strix.report.state import litellm_cost_callback
|
||||||
|
|
||||||
|
for bucket_name in ("success_callback", "_async_success_callback"):
|
||||||
|
bucket = getattr(litellm, bucket_name, None)
|
||||||
|
if not isinstance(bucket, list):
|
||||||
|
continue
|
||||||
|
if litellm_cost_callback in bucket:
|
||||||
|
continue
|
||||||
|
bucket.append(litellm_cost_callback)
|
||||||
|
|
||||||
|
|
||||||
def _configure_litellm_default(name: str, value: str) -> None:
|
def _configure_litellm_default(name: str, value: str) -> None:
|
||||||
@@ -71,30 +128,35 @@ def _configure_litellm_default(name: str, value: str) -> None:
|
|||||||
setattr(litellm, name, value)
|
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
|
|
||||||
|
|
||||||
|
|
||||||
def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bool:
|
def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bool:
|
||||||
"""Return whether the resolved SDK route can only receive JSON function tools."""
|
"""Return whether the resolved SDK route can only receive JSON function tools."""
|
||||||
model = model_name.strip().lower()
|
model = model_name.strip().lower()
|
||||||
if model.startswith(("litellm/", "any-llm/")):
|
if "/" in model and not model.startswith("openai/"):
|
||||||
return True
|
return True
|
||||||
return bool(settings.llm.api_base)
|
if settings.llm.api_base:
|
||||||
|
return True
|
||||||
|
return not model_supports_reasoning(model_name)
|
||||||
|
|
||||||
|
|
||||||
|
def model_supports_reasoning(model_name: str) -> bool:
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
name = model_name.strip().lower()
|
||||||
|
for prefix in ("litellm/", "any-llm/", "openai/"):
|
||||||
|
if name.startswith(prefix):
|
||||||
|
name = name[len(prefix) :]
|
||||||
|
break
|
||||||
|
entry = litellm.model_cost.get(name)
|
||||||
|
if entry is None and "/" in name:
|
||||||
|
entry = litellm.model_cost.get(name.rsplit("/", 1)[1])
|
||||||
|
return bool(entry and entry.get("supports_reasoning"))
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_openai_bare_model(model_name: str) -> bool:
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
name = model_name.strip().lower()
|
||||||
|
if not name or "/" in name:
|
||||||
|
return False
|
||||||
|
entry = litellm.model_cost.get(name)
|
||||||
|
return bool(entry and entry.get("litellm_provider") == "openai")
|
||||||
|
|||||||
+14
-6
@@ -349,12 +349,20 @@ async def _run_cycle( # noqa: PLR0912
|
|||||||
)
|
)
|
||||||
await coordinator.attach_stream(agent_id, stream)
|
await coordinator.attach_stream(agent_id, stream)
|
||||||
try:
|
try:
|
||||||
async for event in stream.stream_events():
|
try:
|
||||||
if event_sink is not None:
|
async for event in stream.stream_events():
|
||||||
try:
|
if event_sink is not None:
|
||||||
event_sink(agent_id, event)
|
try:
|
||||||
except Exception:
|
event_sink(agent_id, event)
|
||||||
logger.exception("stream event sink failed for %s", agent_id)
|
except Exception:
|
||||||
|
logger.exception("stream event sink failed for %s", agent_id)
|
||||||
|
except RuntimeError as stream_exc:
|
||||||
|
if "after shutdown" not in str(stream_exc):
|
||||||
|
raise
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring LiteLLM end-of-stream shutdown race 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
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from agents.model_settings import ModelSettings
|
from agents.model_settings import ModelSettings
|
||||||
from openai.types.shared import Reasoning
|
from openai.types.shared import Reasoning
|
||||||
|
|
||||||
from strix.config.models import DEFAULT_MODEL_RETRY
|
from strix.config.models import DEFAULT_MODEL_RETRY, model_supports_reasoning
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -108,20 +108,19 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
def make_model_settings(
|
def make_model_settings(
|
||||||
reasoning_effort: ReasoningEffort | None,
|
reasoning_effort: ReasoningEffort | None,
|
||||||
|
*,
|
||||||
|
model_name: str,
|
||||||
) -> ModelSettings:
|
) -> ModelSettings:
|
||||||
# Anthropic + DeepSeek thinking reject ``tool_choice="required"`` outright
|
|
||||||
# when reasoning is enabled; OpenAI o-series accepts both but doesn't need
|
|
||||||
# the safety net. When reasoning is on we let the model self-select tools
|
|
||||||
# and rely on the system prompt + the ``_finish_tool_use_behavior`` callback
|
|
||||||
# to keep the loop converging on a lifecycle tool.
|
|
||||||
use_reasoning = reasoning_effort is not None and reasoning_effort != "none"
|
|
||||||
model_settings = ModelSettings(
|
model_settings = ModelSettings(
|
||||||
parallel_tool_calls=False,
|
parallel_tool_calls=False,
|
||||||
tool_choice=None if use_reasoning else "required",
|
|
||||||
retry=DEFAULT_MODEL_RETRY,
|
retry=DEFAULT_MODEL_RETRY,
|
||||||
include_usage=True,
|
include_usage=True,
|
||||||
)
|
)
|
||||||
if use_reasoning:
|
if (
|
||||||
|
reasoning_effort is not None
|
||||||
|
and reasoning_effort != "none"
|
||||||
|
and model_supports_reasoning(model_name)
|
||||||
|
):
|
||||||
model_settings = model_settings.resolve(
|
model_settings = model_settings.resolve(
|
||||||
ModelSettings(reasoning=Reasoning(effort=reasoning_effort)),
|
ModelSettings(reasoning=Reasoning(effort=reasoning_effort)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ from agents.sandbox import SandboxRunConfig
|
|||||||
from strix.agents.factory import build_strix_agent, make_child_factory
|
from strix.agents.factory import build_strix_agent, make_child_factory
|
||||||
from strix.config import load_settings
|
from strix.config import load_settings
|
||||||
from strix.config.models import (
|
from strix.config.models import (
|
||||||
|
StrixProvider,
|
||||||
configure_sdk_model_defaults,
|
configure_sdk_model_defaults,
|
||||||
normalize_model_name,
|
|
||||||
uses_chat_completions_tool_schema,
|
uses_chat_completions_tool_schema,
|
||||||
)
|
)
|
||||||
from strix.core.agents import AgentCoordinator
|
from strix.core.agents import AgentCoordinator
|
||||||
@@ -90,7 +90,7 @@ async def run_strix_scan(
|
|||||||
|
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
configure_sdk_model_defaults(settings)
|
configure_sdk_model_defaults(settings)
|
||||||
resolved_model = normalize_model_name(model or settings.llm.model or "")
|
resolved_model = (model or settings.llm.model or "").strip()
|
||||||
if not resolved_model:
|
if not resolved_model:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"No LLM model configured. Set STRIX_LLM env or pass model= to run_strix_scan().",
|
"No LLM model configured. Set STRIX_LLM env or pass model= to run_strix_scan().",
|
||||||
@@ -153,9 +153,13 @@ async def run_strix_scan(
|
|||||||
is_whitebox = any(t.get("type") == "local_code" for t in targets)
|
is_whitebox = any(t.get("type") == "local_code" for t in targets)
|
||||||
skills = list(scan_config.get("skills") or [])
|
skills = list(scan_config.get("skills") or [])
|
||||||
root_task = build_root_task(scan_config)
|
root_task = build_root_task(scan_config)
|
||||||
model_settings = make_model_settings(settings.llm.reasoning_effort)
|
model_settings = make_model_settings(
|
||||||
|
settings.llm.reasoning_effort,
|
||||||
|
model_name=resolved_model,
|
||||||
|
)
|
||||||
run_config = RunConfig(
|
run_config = RunConfig(
|
||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
|
model_provider=StrixProvider(),
|
||||||
model_settings=model_settings,
|
model_settings=model_settings,
|
||||||
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
|
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
|
||||||
trace_include_sensitive_data=False,
|
trace_include_sensitive_data=False,
|
||||||
|
|||||||
+42
-6
@@ -12,7 +12,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
from agents.model_settings import ModelSettings
|
from agents.model_settings import ModelSettings
|
||||||
from agents.models.interface import ModelTracing
|
from agents.models.interface import ModelTracing
|
||||||
from agents.models.multi_provider import MultiProvider
|
|
||||||
from docker.errors import DockerException
|
from docker.errors import DockerException
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
@@ -23,7 +22,11 @@ from strix.config import (
|
|||||||
load_settings,
|
load_settings,
|
||||||
persist_current,
|
persist_current,
|
||||||
)
|
)
|
||||||
from strix.config.models import configure_sdk_model_defaults, normalize_model_name
|
from strix.config.models import (
|
||||||
|
StrixProvider,
|
||||||
|
configure_sdk_model_defaults,
|
||||||
|
is_known_openai_bare_model,
|
||||||
|
)
|
||||||
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
|
||||||
@@ -98,7 +101,8 @@ def validate_environment() -> None:
|
|||||||
error_text.append("• ", style="white")
|
error_text.append("• ", style="white")
|
||||||
error_text.append("STRIX_LLM", style="bold cyan")
|
error_text.append("STRIX_LLM", style="bold cyan")
|
||||||
error_text.append(
|
error_text.append(
|
||||||
" - Model name to use (e.g., 'gpt-5.4' or 'claude-sonnet-4-6')\n",
|
" - Model name to use (e.g., 'openai/gpt-5.4' or "
|
||||||
|
"'anthropic/claude-opus-4-7')\n",
|
||||||
style="white",
|
style="white",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,7 +141,7 @@ def validate_environment() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
error_text.append("\nExample setup:\n", style="white")
|
error_text.append("\nExample setup:\n", style="white")
|
||||||
error_text.append("export STRIX_LLM='gpt-5.4'\n", style="dim white")
|
error_text.append("export STRIX_LLM='openai/gpt-5.4'\n", style="dim white")
|
||||||
|
|
||||||
if missing_optional_vars:
|
if missing_optional_vars:
|
||||||
for var in missing_optional_vars:
|
for var in missing_optional_vars:
|
||||||
@@ -215,7 +219,39 @@ async def warm_up_llm() -> None:
|
|||||||
configure_sdk_model_defaults(settings)
|
configure_sdk_model_defaults(settings)
|
||||||
llm = settings.llm
|
llm = settings.llm
|
||||||
|
|
||||||
model = MultiProvider().get_model(normalize_model_name(llm.model or ""))
|
raw_model = (llm.model or "").strip()
|
||||||
|
if (
|
||||||
|
raw_model
|
||||||
|
and "/" not in raw_model
|
||||||
|
and not is_known_openai_bare_model(raw_model)
|
||||||
|
and not llm.api_base
|
||||||
|
):
|
||||||
|
warn_text = Text()
|
||||||
|
warn_text.append("UNKNOWN MODEL NAME", style="bold yellow")
|
||||||
|
warn_text.append("\n\n", style="white")
|
||||||
|
warn_text.append(f"'{raw_model}'", style="bold cyan")
|
||||||
|
warn_text.append(
|
||||||
|
" is not a known OpenAI model. Bare names route to OpenAI by default.\n"
|
||||||
|
"If you meant a non-OpenAI provider, use the '",
|
||||||
|
style="white",
|
||||||
|
)
|
||||||
|
warn_text.append("<provider>/<model>", style="bold cyan")
|
||||||
|
warn_text.append(
|
||||||
|
"' form, e.g. 'anthropic/claude-opus-4-7', 'deepseek/deepseek-v4-pro'.",
|
||||||
|
style="white",
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
warn_text,
|
||||||
|
title="[bold white]STRIX",
|
||||||
|
title_align="left",
|
||||||
|
border_style="yellow",
|
||||||
|
padding=(1, 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
model = StrixProvider().get_model(raw_model)
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
model.get_response(
|
model.get_response(
|
||||||
system_instructions="You are a helpful assistant.",
|
system_instructions="You are a helpful assistant.",
|
||||||
@@ -231,7 +267,7 @@ async def warm_up_llm() -> None:
|
|||||||
),
|
),
|
||||||
timeout=llm.timeout,
|
timeout=llm.timeout,
|
||||||
)
|
)
|
||||||
logger.info("LLM warm-up succeeded for model %s", normalize_model_name(llm.model or ""))
|
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("LLM warm-up failed")
|
logger.exception("LLM warm-up failed")
|
||||||
|
|||||||
+34
-11
@@ -663,9 +663,9 @@ class QuitScreen(ModalScreen): # type: ignore[misc]
|
|||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
event.prevent_default()
|
event.prevent_default()
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "quit":
|
if event.button.id == "quit":
|
||||||
self.app.action_custom_quit()
|
await self.app.action_custom_quit()
|
||||||
else:
|
else:
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
@@ -751,6 +751,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
self.report_state.cleanup()
|
self.report_state.cleanup()
|
||||||
|
|
||||||
def signal_handler(_signum: int, _frame: Any) -> None:
|
def signal_handler(_signum: int, _frame: Any) -> None:
|
||||||
|
self._teardown_sandbox_blocking(timeout=10.0)
|
||||||
self.report_state.cleanup(status="interrupted")
|
self.report_state.cleanup(status="interrupted")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
@@ -1142,9 +1143,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
return (text, Text(), False)
|
return (text, Text(), False)
|
||||||
|
|
||||||
if status == "waiting":
|
if status == "waiting":
|
||||||
keymap = Text()
|
text = Text()
|
||||||
keymap.append("Send message to resume", style="dim")
|
text.append("Send message to resume", style="dim")
|
||||||
return (Text(" "), keymap, False)
|
return (text, Text(), False)
|
||||||
|
|
||||||
if status == "running":
|
if status == "running":
|
||||||
if self._agent_has_real_activity(agent_id):
|
if self._agent_has_real_activity(agent_id):
|
||||||
@@ -1632,9 +1633,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
|
|
||||||
self.push_screen(HelpScreen())
|
self.push_screen(HelpScreen())
|
||||||
|
|
||||||
def action_request_quit(self) -> None:
|
async def action_request_quit(self) -> None:
|
||||||
if self.show_splash or not self.is_mounted:
|
if self.show_splash or not self.is_mounted:
|
||||||
self.action_custom_quit()
|
await self.action_custom_quit()
|
||||||
return
|
return
|
||||||
|
|
||||||
if len(self.screen_stack) > 1:
|
if len(self.screen_stack) > 1:
|
||||||
@@ -1643,7 +1644,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
try:
|
try:
|
||||||
self.query_one("#main_container")
|
self.query_one("#main_container")
|
||||||
except (ValueError, Exception):
|
except (ValueError, Exception):
|
||||||
self.action_custom_quit()
|
await self.action_custom_quit()
|
||||||
return
|
return
|
||||||
|
|
||||||
self.push_screen(QuitScreen())
|
self.push_screen(QuitScreen())
|
||||||
@@ -1703,16 +1704,38 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
self._scan_loop,
|
self._scan_loop,
|
||||||
)
|
)
|
||||||
|
|
||||||
def action_custom_quit(self) -> None:
|
async def action_custom_quit(self) -> None:
|
||||||
|
await asyncio.to_thread(self._teardown_sandbox_blocking, timeout=10.0)
|
||||||
|
|
||||||
if self._scan_thread and self._scan_thread.is_alive():
|
if self._scan_thread and self._scan_thread.is_alive():
|
||||||
self._scan_stop_event.set()
|
self._scan_stop_event.set()
|
||||||
|
self._scan_thread.join(timeout=2.0)
|
||||||
self._scan_thread.join(timeout=1.0)
|
|
||||||
|
|
||||||
self.report_state.cleanup()
|
self.report_state.cleanup()
|
||||||
|
|
||||||
self.exit()
|
self.exit()
|
||||||
|
|
||||||
|
def _teardown_sandbox_blocking(self, *, timeout: float) -> None:
|
||||||
|
loop = self._scan_loop
|
||||||
|
if loop is None or loop.is_closed():
|
||||||
|
return
|
||||||
|
run_name = self.scan_config.get("run_name")
|
||||||
|
if not run_name:
|
||||||
|
return
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
session_manager.cleanup(run_name),
|
||||||
|
loop,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
future.result(timeout=timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
"Sandbox cleanup timed out after %.1fs; container may still be running",
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Sandbox cleanup failed")
|
||||||
|
|
||||||
def _is_widget_safe(self, widget: Any) -> bool:
|
def _is_widget_safe(self, widget: Any) -> bool:
|
||||||
try:
|
try:
|
||||||
_ = widget.screen
|
_ = widget.screen
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from agents.model_settings import ModelSettings
|
from agents.model_settings import ModelSettings
|
||||||
from agents.models.interface import ModelTracing
|
from agents.models.interface import ModelTracing
|
||||||
from agents.models.multi_provider import MultiProvider
|
|
||||||
from openai.types.responses import ResponseOutputMessage
|
from openai.types.responses import ResponseOutputMessage
|
||||||
|
|
||||||
from strix.config import load_settings
|
from strix.config import load_settings
|
||||||
from strix.config.models import (
|
from strix.config.models import (
|
||||||
DEFAULT_MODEL_RETRY,
|
DEFAULT_MODEL_RETRY,
|
||||||
|
StrixProvider,
|
||||||
configure_sdk_model_defaults,
|
configure_sdk_model_defaults,
|
||||||
normalize_model_name,
|
|
||||||
)
|
)
|
||||||
from strix.report.state import get_global_report_state
|
from strix.report.state import get_global_report_state
|
||||||
|
|
||||||
@@ -188,8 +187,8 @@ async def check_duplicate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
configure_sdk_model_defaults(settings)
|
configure_sdk_model_defaults(settings)
|
||||||
resolved_model = normalize_model_name(model_name)
|
resolved_model = model_name.strip()
|
||||||
model = MultiProvider().get_model(resolved_model)
|
model = StrixProvider().get_model(resolved_model)
|
||||||
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,
|
||||||
|
|||||||
@@ -230,6 +230,9 @@ class ReportState:
|
|||||||
):
|
):
|
||||||
self.save_run_data()
|
self.save_run_data()
|
||||||
|
|
||||||
|
def record_observed_llm_cost(self, cost: float) -> None:
|
||||||
|
self._llm_usage.record_observed_cost(cost)
|
||||||
|
|
||||||
def get_total_llm_usage(self) -> dict[str, Any]:
|
def get_total_llm_usage(self) -> dict[str, Any]:
|
||||||
return dict(self.run_record.get("llm_usage") or self._build_llm_usage_record())
|
return dict(self.run_record.get("llm_usage") or self._build_llm_usage_record())
|
||||||
|
|
||||||
@@ -343,3 +346,45 @@ class ReportState:
|
|||||||
def _hydrate_llm_usage(self, raw_usage: Any) -> None:
|
def _hydrate_llm_usage(self, raw_usage: Any) -> None:
|
||||||
self._llm_usage.hydrate(raw_usage)
|
self._llm_usage.hydrate(raw_usage)
|
||||||
self._sync_llm_usage_record()
|
self._sync_llm_usage_record()
|
||||||
|
|
||||||
|
|
||||||
|
def litellm_cost_callback(
|
||||||
|
kwargs: Any,
|
||||||
|
completion_response: Any,
|
||||||
|
_start_time: Any = None,
|
||||||
|
_end_time: Any = None,
|
||||||
|
) -> None:
|
||||||
|
"""LiteLLM ``success_callback`` adapter; forwards observed cost to the active scan."""
|
||||||
|
cost: float | None = None
|
||||||
|
raw = kwargs.get("response_cost") if isinstance(kwargs, dict) else None
|
||||||
|
if isinstance(raw, int | float) and raw > 0:
|
||||||
|
cost = float(raw)
|
||||||
|
|
||||||
|
if cost is None:
|
||||||
|
hidden = getattr(completion_response, "_hidden_params", None) or {}
|
||||||
|
candidate = hidden.get("response_cost") if isinstance(hidden, dict) else None
|
||||||
|
if isinstance(candidate, int | float) and candidate > 0:
|
||||||
|
cost = float(candidate)
|
||||||
|
else:
|
||||||
|
headers = hidden.get("additional_headers") or {} if isinstance(hidden, dict) else {}
|
||||||
|
raw = (
|
||||||
|
headers.get("llm_provider-x-litellm-response-cost")
|
||||||
|
if isinstance(headers, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
value = float(raw) if raw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = None
|
||||||
|
if value is not None and value > 0:
|
||||||
|
cost = value
|
||||||
|
|
||||||
|
if cost is None or cost <= 0:
|
||||||
|
return
|
||||||
|
report_state = get_global_report_state()
|
||||||
|
if report_state is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
report_state.record_observed_llm_cost(cost)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to record observed LiteLLM cost")
|
||||||
|
|||||||
+52
-28
@@ -19,7 +19,6 @@ class LLMUsageLedger:
|
|||||||
self._agent_usage: dict[str, Usage] = {}
|
self._agent_usage: dict[str, Usage] = {}
|
||||||
self._agent_metadata: dict[str, dict[str, str]] = {}
|
self._agent_metadata: dict[str, dict[str, str]] = {}
|
||||||
self._total_cost = 0.0
|
self._total_cost = 0.0
|
||||||
self._agent_cost: dict[str, float] = {}
|
|
||||||
|
|
||||||
def record(
|
def record(
|
||||||
self,
|
self,
|
||||||
@@ -33,8 +32,6 @@ class LLMUsageLedger:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
normalized_agent_id = str(agent_id or "unknown")
|
normalized_agent_id = str(agent_id or "unknown")
|
||||||
estimated_cost = _estimate_litellm_cost(usage, model)
|
|
||||||
|
|
||||||
self._total_usage.add(usage)
|
self._total_usage.add(usage)
|
||||||
self._agent_usage.setdefault(normalized_agent_id, Usage()).add(usage)
|
self._agent_usage.setdefault(normalized_agent_id, Usage()).add(usage)
|
||||||
|
|
||||||
@@ -44,31 +41,38 @@ class LLMUsageLedger:
|
|||||||
if model:
|
if model:
|
||||||
metadata["model"] = model
|
metadata["model"] = model
|
||||||
|
|
||||||
if estimated_cost is not None:
|
if not _is_litellm_routed(model):
|
||||||
self._total_cost += estimated_cost
|
estimated = _estimate_litellm_cost(usage, model)
|
||||||
self._agent_cost[normalized_agent_id] = (
|
if estimated:
|
||||||
self._agent_cost.get(normalized_agent_id, 0.0) + estimated_cost
|
self._total_cost += estimated
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def record_observed_cost(self, cost: float) -> None:
|
||||||
|
if isinstance(cost, int | float) and cost > 0:
|
||||||
|
self._total_cost += float(cost)
|
||||||
|
|
||||||
def to_record(self) -> dict[str, Any]:
|
def to_record(self) -> dict[str, Any]:
|
||||||
record = serialize_usage(self._total_usage)
|
record = serialize_usage(self._total_usage)
|
||||||
record["cost"] = _round_cost(self._total_cost)
|
record["cost"] = _round_cost(self._total_cost)
|
||||||
record["cost_source"] = "litellm_estimate"
|
|
||||||
record["agents"] = []
|
record["agents"] = []
|
||||||
|
|
||||||
|
agent_tokens = {aid: _resolve_total_tokens(u) for aid, u in self._agent_usage.items()}
|
||||||
|
total_tokens = sum(agent_tokens.values())
|
||||||
for agent_id in sorted(self._agent_usage):
|
for agent_id in sorted(self._agent_usage):
|
||||||
usage = self._agent_usage[agent_id]
|
usage = self._agent_usage[agent_id]
|
||||||
metadata = self._agent_metadata.get(agent_id, {})
|
metadata = self._agent_metadata.get(agent_id, {})
|
||||||
|
agent_cost = (
|
||||||
|
self._total_cost * (agent_tokens[agent_id] / total_tokens) if total_tokens else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
agent_record = serialize_usage(usage)
|
agent_record = serialize_usage(usage)
|
||||||
agent_record.update(
|
agent_record.update(
|
||||||
{
|
{
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"agent_name": metadata.get("agent_name") or agent_id,
|
"agent_name": metadata.get("agent_name") or agent_id,
|
||||||
"model": metadata.get("model"),
|
"model": metadata.get("model"),
|
||||||
"cost": _round_cost(self._agent_cost.get(agent_id, 0.0)),
|
"cost": _round_cost(agent_cost),
|
||||||
"cost_source": "litellm_estimate",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
record["agents"].append(agent_record)
|
record["agents"].append(agent_record)
|
||||||
@@ -80,7 +84,6 @@ class LLMUsageLedger:
|
|||||||
self._agent_usage.clear()
|
self._agent_usage.clear()
|
||||||
self._agent_metadata.clear()
|
self._agent_metadata.clear()
|
||||||
self._total_cost = 0.0
|
self._total_cost = 0.0
|
||||||
self._agent_cost.clear()
|
|
||||||
|
|
||||||
if not isinstance(raw_usage, dict):
|
if not isinstance(raw_usage, dict):
|
||||||
return
|
return
|
||||||
@@ -92,11 +95,8 @@ class LLMUsageLedger:
|
|||||||
self._total_usage = Usage()
|
self._total_usage = Usage()
|
||||||
|
|
||||||
self._total_cost = _float_or_zero(raw_usage.get("cost"))
|
self._total_cost = _float_or_zero(raw_usage.get("cost"))
|
||||||
agents = raw_usage.get("agents") or []
|
|
||||||
if not isinstance(agents, list):
|
|
||||||
return
|
|
||||||
|
|
||||||
for raw_agent in agents:
|
for raw_agent in raw_usage.get("agents") or []:
|
||||||
if not isinstance(raw_agent, dict):
|
if not isinstance(raw_agent, dict):
|
||||||
continue
|
continue
|
||||||
agent_id = str(raw_agent.get("agent_id") or "").strip()
|
agent_id = str(raw_agent.get("agent_id") or "").strip()
|
||||||
@@ -116,7 +116,24 @@ class LLMUsageLedger:
|
|||||||
if isinstance(model, str) and model:
|
if isinstance(model, str) and model:
|
||||||
metadata["model"] = model
|
metadata["model"] = model
|
||||||
self._agent_metadata[agent_id] = metadata
|
self._agent_metadata[agent_id] = metadata
|
||||||
self._agent_cost[agent_id] = _float_or_zero(raw_agent.get("cost"))
|
|
||||||
|
|
||||||
|
def _resolve_total_tokens(usage: Usage) -> int:
|
||||||
|
total = max(0, int(usage.total_tokens or 0))
|
||||||
|
if total > 0:
|
||||||
|
return total
|
||||||
|
prompt = _int_or_zero(getattr(usage, "input_tokens", 0))
|
||||||
|
completion = _int_or_zero(getattr(usage, "output_tokens", 0))
|
||||||
|
return prompt + completion
|
||||||
|
|
||||||
|
|
||||||
|
def _is_litellm_routed(model: str | None) -> bool:
|
||||||
|
if not model:
|
||||||
|
return False
|
||||||
|
name = model.strip().lower()
|
||||||
|
if "/" not in name:
|
||||||
|
return False
|
||||||
|
return not name.startswith("openai/")
|
||||||
|
|
||||||
|
|
||||||
def _usage_has_activity(usage: Usage) -> bool:
|
def _usage_has_activity(usage: Usage) -> bool:
|
||||||
@@ -171,18 +188,25 @@ def _estimate_litellm_entry_cost(entry: Any, model: str) -> float | None:
|
|||||||
if completion_details:
|
if completion_details:
|
||||||
usage_payload["completion_tokens_details"] = completion_details
|
usage_payload["completion_tokens_details"] = completion_details
|
||||||
|
|
||||||
try:
|
from litellm import completion_cost
|
||||||
from litellm import completion_cost
|
|
||||||
|
|
||||||
cost = completion_cost(
|
candidates = [model]
|
||||||
completion_response={
|
if "/" in model:
|
||||||
"model": model.split("/", 1)[-1],
|
candidates.append(model.split("/", 1)[-1])
|
||||||
"usage": usage_payload,
|
|
||||||
},
|
cost: Any = None
|
||||||
model=model,
|
for candidate in candidates:
|
||||||
)
|
try:
|
||||||
except Exception: # noqa: BLE001 - LiteLLM raises plain Exception for unknown model prices.
|
cost = completion_cost(
|
||||||
logger.debug("LiteLLM cost estimate unavailable for model %s", model, exc_info=True)
|
completion_response={"model": candidate, "usage": usage_payload},
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception: # nosec B112 # noqa: BLE001, S112
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cost is None:
|
||||||
|
logger.debug("LiteLLM cost estimate unavailable for model %s", model)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return cost if isinstance(cost, int | float) and cost >= 0 else None
|
return cost if isinstance(cost, int | float) and cost >= 0 else None
|
||||||
|
|||||||
@@ -956,7 +956,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litellm"
|
name = "litellm"
|
||||||
version = "1.83.7"
|
version = "1.88.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -972,9 +972,9 @@ dependencies = [
|
|||||||
{ name = "tiktoken" },
|
{ name = "tiktoken" },
|
||||||
{ name = "tokenizers" },
|
{ name = "tokenizers" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633", size = 17791694, upload-time = "2026-04-13T17:35:01.606Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/8c/6cfce5d15554e076b8438a955436e9e6e2a2bd39c76539656c1c3861c369/litellm-1.88.0.tar.gz", hash = "sha256:4ff794493e40bd86c6f13e91dcb3e1aad697403fd46a96902196d93356ba48f4", size = 13886026, upload-time = "2026-06-06T23:25:17.93Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807, upload-time = "2026-04-13T17:34:58.36Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/71/deb6637475253b83eb4577c058863eec4b4ddaef2d08dcd93981b1aa4209/litellm-1.88.0-py3-none-any.whl", hash = "sha256:abd3037e0bf5703f833f5565c87bdfd93578d3de46cbcb36dfa108c3ef58021c", size = 15276203, upload-time = "2026-06-06T23:25:07.257Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2035,7 +2035,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strix-agent"
|
name = "strix-agent"
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "caido-sdk-client" },
|
{ name = "caido-sdk-client" },
|
||||||
|
|||||||
Reference in New Issue
Block a user