18 Commits

Author SHA1 Message Date
Ahmed Allam 250fe2cf3e Bump 1.0.2 -> 1.0.3 (#537) 2026-06-08 18:05:02 -07:00
Ahmed Allam 6c99829325 Simplify cost ledger to one bucket (#531) 2026-06-08 15:56:28 -07:00
Ahmed Allam 1c9ab993bb Use observed LiteLLM cost for LiteLLM-routed calls (#529)
Register a litellm.success_callback that captures kwargs['response_cost']
into a new observed-cost bucket on LLMUsageLedger. record() skips the
tokens-times-registry estimate for LiteLLM-routed models so we do not
double-count with the callback; OpenAI direct routes keep estimating
since LiteLLM is not invoked for them. Per-agent attribution for
LiteLLM-routed calls is apportioned by token share at to_record() time.
2026-06-08 15:01:48 -07:00
Ahmed Allam 04eb03febe Gate Reasoning(effort=...) on registry support (#528)
OpenAI's Responses API rejects reasoning.effort on non-reasoning
models like gpt-4o with `unsupported_parameter`, so any scan with
the default STRIX_REASONING_EFFORT=high against gpt-4o crashed at
the first model call. drop_params=True absorbs the rejected param
on LiteLLM-routed models but the SDK's native OpenAI path has no
equivalent.

Lift model_supports_reasoning to a public helper that strips
litellm/, any-llm/, openai/ prefixes and falls back to last-segment
lookup so prefixed forms like anthropic/claude-opus-4-7 resolve
through the bare model_cost entry. make_model_settings regains
model_name and skips Reasoning() when the registry doesn't confirm
support. uses_chat_completions_tool_schema reuses the same helper
(was duplicating the lookup under a misleading name).
2026-06-08 13:18:07 -07:00
Ahmed Allam ac0fef2ed7 Show "Send message to resume" on the left of the status bar (#525) 2026-06-07 17:41:06 -07:00
0xallam dcf3155a9a Use function-tool schema for non-reasoning OpenAI models
OpenAI's Responses API rejects tools[i].type="custom" on non-reasoning
models like gpt-4o (400 with code=unknown_parameter, param=tools).
Strix's SDK-native Filesystem capability registers CustomTool entries
by default, so a bare STRIX_LLM=gpt-4o run failed at the first tool
invocation even though warm-up (a tool-less call) succeeded.

uses_chat_completions_tool_schema now consults
litellm.model_cost[<name>].supports_reasoning for OpenAI routes and
flips to the chat-completions function-tool schema for models that
don't carry the reasoning flag. Same registry-lookup pattern as
is_known_openai_bare_model. Non-OpenAI prefixes and configs with
LLM_API_BASE are unchanged (still function tools).
2026-06-07 17:36:19 -07:00
0xallam 36b374bd1b Bump litellm 1.83.7 -> 1.88.0 2026-06-07 17:36:19 -07:00
0xallam 1a329e8972 Suppress LiteLLM stdout banner spam
litellm.suppress_debug_info silences two unsolicited print() calls in
LiteLLM core: the "Provider List: https://docs.litellm.ai/docs/providers"
banner emitted by get_llm_provider_logic and the "Give Feedback /
Get Help" + "If you need to debug this error, use litellm._turn_on_debug()"
pair emitted by exception_mapping_utils on every LiteLLM exception.
Both are unconditional print() calls, not logger output, so log-level
config can't catch them. LiteLLM's own router and proxy_server set the
same flag for the same reason.
2026-06-07 17:36:19 -07:00
0xallam 143b9e7040 Pre-warm-up unknown-model warning + LiteLLM streaming hardening
Warn on bare unknown model names before warm-up. is_known_openai_bare_model
consults litellm.model_cost and matches only entries whose
litellm_provider == "openai". When the configured STRIX_LLM has no
provider prefix, isn't a known OpenAI model, and no LLM_API_BASE is
set, show a clear panel pointing the user at the <provider>/<model>
form and exit before issuing the doomed request — no more chasing an
"Incorrect API key" 401 from OpenAI when the user actually meant
deepseek/, anthropic/, etc. Custom-base configs are still allowed
through unconfirmed.

Disable LiteLLM's message-logging and streaming-logging knobs to cut
noise and skip one of the two end-of-stream submit paths. The other
path at streaming_handler.py:2206 schedules work on a global
ThreadPoolExecutor that loses to atexit shutdown when the interpreter
is winding down; the SDK's stream consumer surfaces that as a fatal
"cannot schedule new futures after shutdown" RuntimeError even though
the actual stream content was already delivered. Catch and swallow
that specific RuntimeError in _run_cycle so the scan isn't killed by
an upstream end-of-stream logging race.
2026-06-07 17:36:19 -07:00
0xallam 3665a7899f Strip model-aware branches from LLM configuration
Drop every hand-rolled provider table and per-model gating that had
accumulated in the model-handling layer:

  * normalize_model_name no longer auto-prefixes bare claude-* / gemini-*
    names. Users supply the full <provider>/<model> form. The function
    became literally model_name.strip(), so callers now inline that and
    the function is removed.
  * tool_choice="required" is gone everywhere. Thinking-mode endpoints
    (Anthropic, DeepSeek /beta) reject it; modern reasoning models don't
    need it; non-interactive runs already have
    _append_noninteractive_tool_required_message as the convergence
    backstop. model_supports_reasoning, model_known_to_registry, and
    _model_cost_entry were only used to gate this and follow it out.
  * Reasoning(effort=...) is now attached whenever
    STRIX_REASONING_EFFORT is non-none. litellm.drop_params=True absorbs
    it for non-reasoning models.
  * Warm-up's bare-name OpenAI 401 hint is removed (false-positive prone,
    relied on substring matching).
  * reset_tool_choice on SandboxAgent is no-op now (no tool_choice gets
    set) and is removed.
  * report/dedupe.py was still routing through stock MultiProvider, so
    non-OpenAI configs failed the dedupe LLM pass; switch it to
    StrixProvider.

Verified end-to-end against modern provider strings (openai/gpt-5.4,
anthropic/claude-opus-4-7, deepseek/deepseek-reasoner,
gemini/gemini-2.5-pro, groq/, xai/, mistral/, together_ai/, perplexity/,
openrouter/, litellm/ legacy form, and whitespace-padded input): 18/18
cases route correctly, env vars mirror via litellm.validate_environment,
and ModelSettings carries no tool_choice. mypy strict passes.
2026-06-07 17:36:19 -07:00
0xallam 232711be8c Stop exposing litellm/ prefix in user-facing model names
Users had to type STRIX_LLM=litellm/deepseek/deepseek-chat — the
litellm/ wrapper was Strix-internal plumbing surfacing in user config.

Add StrixProvider, a MultiProvider subclass that routes any non-OpenAI
prefix (deepseek/, anthropic/, groq/, xai/, mistral/, openrouter/, …)
through LitellmProvider with the prefix preserved. normalize_model_name
no longer adds litellm/ to anything; bare claude-* / gemini-* shorthands
expand to anthropic/<model> / gemini/<model> instead of the wrapped form.

Wire StrixProvider into warm_up_llm and RunConfig.model_provider.
litellm/<provider>/<model> and any-llm/<provider>/<model> still resolve
unchanged for users on older config.

Refresh stale model names in the env-validation messages and the
warm-up hint (gpt-5.4, claude-opus-4-7, deepseek-reasoner).

Verified 24-case end-to-end matrix: OpenAI direct vs. LitellmProvider
routing, env-var mirroring via validate_environment, supports_reasoning
detection, and tool_choice gating all behave correctly across modern
providers including the user's unknown DeepSeek SKU.
2026-06-07 17:36:19 -07:00
0xallam 712c64f630 Drop tool_choice for registry-unknown reasoning-effort runs
When the user opts into reasoning_effort but the configured model
isn't in litellm.model_cost at all (private SKUs, fresh releases the
registry hasn't picked up — e.g. deepseek/deepseek-v4-pro), we can't
confirm thinking support and were sending tool_choice="required",
which thinking-mode endpoints reject ("Thinking mode does not support
this tool_choice").

Add model_known_to_registry() and split the decision: when the user
wants reasoning AND the model is either confirmed-reasoning OR
unknown-to-registry, drop tool_choice. The Reasoning(effort=...) param
still only attaches for confirmed-reasoning models, so we don't send
reasoning hints to known non-reasoning models.

Known non-reasoning models (gpt-4o, registry-confirmed) keep
tool_choice="required" unchanged.
2026-06-07 17:36:19 -07:00
0xallam dee2a03d07 Hint at provider prefix when bare model 401s against OpenAI
A bare model name without a provider prefix routes through the SDK's
default OpenAI provider, so configuring STRIX_LLM=deepseek-v4-pro with
LLM_API_KEY=<deepseek key> sends that key to api.openai.com and
surfaces a confusing "Incorrect API key" error pointing at the OpenAI
dashboard.

When warm-up fails with an OpenAI-shaped error AND the configured
model is still unprefixed after normalize_model_name, append a hint
that points the user at the '<provider>/<model>' form with concrete
examples.
2026-06-07 17:36:19 -07:00
0xallam 1473fc7336 Use validate_environment to resolve provider env var
Naively uppercasing the routing prefix breaks for providers whose
LiteLLM env var name doesn't match the prefix verbatim:
  together_ai/...  needs TOGETHERAI_API_KEY  (no underscore)
  perplexity/...   needs PERPLEXITYAI_API_KEY

Ask LiteLLM directly via litellm.validate_environment(model=...) which
env vars it consults for the chosen provider, then setdefault each one
to LLM_API_KEY. This is the SDK-blessed lookup and stays correct for
every provider LiteLLM supports without a hand-maintained name map.

Lowercase the routed model name before lookup so mixed-case user input
(e.g. Together_AI/...) still resolves.
2026-06-07 17:36:19 -07:00
0xallam dd1f816f7c Cover bare claude-/gemini- shorthands in env mirror
normalize_model_name expands `claude-*` and `gemini-*` shorthands into
`litellm/anthropic/...` and `litellm/gemini/...` at routing time, but
the mirror helper was looking at the raw pre-normalization name — bare
shorthands had no `/` and hit the early return, so ANTHROPIC_API_KEY /
GEMINI_API_KEY were never populated for those users.

Run the same normalization inside the mirror helper so the provider
prefix is consistent with what LiteLLM actually sees downstream.
2026-06-07 17:36:19 -07:00
0xallam 9ab70c6d61 Mirror LLM_API_KEY to provider env var (closes #504)
LiteLLM's per-provider branches (deepseek, anthropic, groq, etc.)
don't consult ``litellm.api_key`` (the module global Strix sets).
They only check the per-call ``api_key`` kwarg and the
``<PROVIDER>_API_KEY`` env var. The SDK's LitellmModel passes
``api_key=None`` by default, so requests went out with an empty
bearer and DeepSeek (and friends) returned 401.

Mirror the user's LLM_API_KEY into the provider-specific env var
(``DEEPSEEK_API_KEY`` for ``deepseek/...``, ``ANTHROPIC_API_KEY``
for ``anthropic/...``, etc.) using LiteLLM's documented convention.
``os.environ.setdefault`` is used so an explicit user env is never
clobbered. The OpenAI branch was already working via
``set_default_openai_key`` + the existing ``litellm.api_key`` global
fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 17:36:19 -07:00
Ahmed Allam 13046cc74a fix: gate reasoning_effort by LiteLLM model registry (closes #517) (#523)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 12:24:48 -07:00
Ahmed Allam 1aad460f6e fix: SDK tracing leak + orphan docker on TUI quit (closes #512) (#522)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 11:28:06 -07:00
12 changed files with 306 additions and 105 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.0.2"
version = "1.0.3"
description = "Open-source AI Hackers for your apps"
readme = "README.md"
license = "Apache-2.0"
@@ -219,6 +219,8 @@ ignore = [
# ReportState carries scan artifact/report fields and
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
"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;
# splitting would obscure the decision tree without simplifying it.
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
-1
View File
@@ -395,7 +395,6 @@ def build_strix_agent(
instructions=instructions,
tools=tools,
tool_use_behavior=_finish_tool_use_behavior,
reset_tool_choice=interactive,
model=None,
capabilities=[
Filesystem(
+94 -32
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
import os
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 (
ModelRetryBackoffSettings,
ModelRetrySettings,
@@ -14,10 +15,31 @@ from agents.retry import (
if TYPE_CHECKING:
from agents.models.interface import ModelProvider
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(
@@ -37,17 +59,14 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
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`.
"""
"""Apply Strix config to SDK-native defaults."""
llm = settings.llm
set_tracing_disabled(True)
_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)
_mirror_api_key_to_provider_env(llm.model, llm.api_key)
if llm.api_base:
os.environ["OPENAI_BASE_URL"] = 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")
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:
"""Enable LiteLLM's permissive param-handling mode."""
"""Enable LiteLLM's permissive param handling and disable its callbacks."""
import litellm
litellm.drop_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:
@@ -71,30 +128,35 @@ def _configure_litellm_default(name: str, value: str) -> None:
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:
"""Return whether the resolved SDK route can only receive JSON function tools."""
model = model_name.strip().lower()
if model.startswith(("litellm/", "any-llm/")):
if "/" in model and not model.startswith("openai/"):
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
View File
@@ -349,12 +349,20 @@ async def _run_cycle( # noqa: PLR0912
)
await coordinator.attach_stream(agent_id, stream)
try:
async for event in stream.stream_events():
if event_sink is not None:
try:
event_sink(agent_id, event)
except Exception:
logger.exception("stream event sink failed for %s", agent_id)
try:
async for event in stream.stream_events():
if event_sink is not None:
try:
event_sink(agent_id, event)
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:
raise stream.run_loop_exception
finally:
+8 -9
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
from agents.model_settings import ModelSettings
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:
@@ -108,20 +108,19 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
def make_model_settings(
reasoning_effort: ReasoningEffort | None,
*,
model_name: str,
) -> 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(
parallel_tool_calls=False,
tool_choice=None if use_reasoning else "required",
retry=DEFAULT_MODEL_RETRY,
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(
ModelSettings(reasoning=Reasoning(effort=reasoning_effort)),
)
+7 -3
View File
@@ -15,8 +15,8 @@ from agents.sandbox import SandboxRunConfig
from strix.agents.factory import build_strix_agent, make_child_factory
from strix.config import load_settings
from strix.config.models import (
StrixProvider,
configure_sdk_model_defaults,
normalize_model_name,
uses_chat_completions_tool_schema,
)
from strix.core.agents import AgentCoordinator
@@ -90,7 +90,7 @@ async def run_strix_scan(
settings = load_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:
raise RuntimeError(
"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)
skills = list(scan_config.get("skills") or [])
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(
model=resolved_model,
model_provider=StrixProvider(),
model_settings=model_settings,
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
trace_include_sensitive_data=False,
+42 -6
View File
@@ -12,7 +12,6 @@ from pathlib import Path
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
@@ -23,7 +22,11 @@ from strix.config import (
load_settings,
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.interface.cli import run_cli
from strix.interface.tui import run_tui
@@ -98,7 +101,8 @@ def validate_environment() -> None:
error_text.append("", style="white")
error_text.append("STRIX_LLM", style="bold cyan")
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",
)
@@ -137,7 +141,7 @@ def validate_environment() -> None:
)
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:
for var in missing_optional_vars:
@@ -215,7 +219,39 @@ async def warm_up_llm() -> None:
configure_sdk_model_defaults(settings)
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(
model.get_response(
system_instructions="You are a helpful assistant.",
@@ -231,7 +267,7 @@ async def warm_up_llm() -> None:
),
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:
logger.exception("LLM warm-up failed")
+34 -11
View File
@@ -663,9 +663,9 @@ class QuitScreen(ModalScreen): # type: ignore[misc]
self.app.pop_screen()
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":
self.app.action_custom_quit()
await self.app.action_custom_quit()
else:
self.app.pop_screen()
@@ -751,6 +751,7 @@ class StrixTUIApp(App): # type: ignore[misc]
self.report_state.cleanup()
def signal_handler(_signum: int, _frame: Any) -> None:
self._teardown_sandbox_blocking(timeout=10.0)
self.report_state.cleanup(status="interrupted")
sys.exit(0)
@@ -1142,9 +1143,9 @@ class StrixTUIApp(App): # type: ignore[misc]
return (text, Text(), False)
if status == "waiting":
keymap = Text()
keymap.append("Send message to resume", style="dim")
return (Text(" "), keymap, False)
text = Text()
text.append("Send message to resume", style="dim")
return (text, Text(), False)
if status == "running":
if self._agent_has_real_activity(agent_id):
@@ -1632,9 +1633,9 @@ class StrixTUIApp(App): # type: ignore[misc]
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:
self.action_custom_quit()
await self.action_custom_quit()
return
if len(self.screen_stack) > 1:
@@ -1643,7 +1644,7 @@ class StrixTUIApp(App): # type: ignore[misc]
try:
self.query_one("#main_container")
except (ValueError, Exception):
self.action_custom_quit()
await self.action_custom_quit()
return
self.push_screen(QuitScreen())
@@ -1703,16 +1704,38 @@ class StrixTUIApp(App): # type: ignore[misc]
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():
self._scan_stop_event.set()
self._scan_thread.join(timeout=1.0)
self._scan_thread.join(timeout=2.0)
self.report_state.cleanup()
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:
try:
_ = widget.screen
+3 -4
View File
@@ -8,14 +8,13 @@ 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.config.models import (
DEFAULT_MODEL_RETRY,
StrixProvider,
configure_sdk_model_defaults,
normalize_model_name,
)
from strix.report.state import get_global_report_state
@@ -188,8 +187,8 @@ async def check_duplicate(
)
configure_sdk_model_defaults(settings)
resolved_model = normalize_model_name(model_name)
model = MultiProvider().get_model(resolved_model)
resolved_model = model_name.strip()
model = StrixProvider().get_model(resolved_model)
response = await model.get_response(
system_instructions=DEDUPE_SYSTEM_PROMPT,
input=user_msg,
+45
View File
@@ -230,6 +230,9 @@ class ReportState:
):
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]:
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:
self._llm_usage.hydrate(raw_usage)
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
View File
@@ -19,7 +19,6 @@ class LLMUsageLedger:
self._agent_usage: dict[str, Usage] = {}
self._agent_metadata: dict[str, dict[str, str]] = {}
self._total_cost = 0.0
self._agent_cost: dict[str, float] = {}
def record(
self,
@@ -33,8 +32,6 @@ class LLMUsageLedger:
return False
normalized_agent_id = str(agent_id or "unknown")
estimated_cost = _estimate_litellm_cost(usage, model)
self._total_usage.add(usage)
self._agent_usage.setdefault(normalized_agent_id, Usage()).add(usage)
@@ -44,31 +41,38 @@ class LLMUsageLedger:
if model:
metadata["model"] = model
if estimated_cost is not None:
self._total_cost += estimated_cost
self._agent_cost[normalized_agent_id] = (
self._agent_cost.get(normalized_agent_id, 0.0) + estimated_cost
)
if not _is_litellm_routed(model):
estimated = _estimate_litellm_cost(usage, model)
if estimated:
self._total_cost += estimated
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]:
record = serialize_usage(self._total_usage)
record["cost"] = _round_cost(self._total_cost)
record["cost_source"] = "litellm_estimate"
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):
usage = self._agent_usage[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.update(
{
"agent_id": agent_id,
"agent_name": metadata.get("agent_name") or agent_id,
"model": metadata.get("model"),
"cost": _round_cost(self._agent_cost.get(agent_id, 0.0)),
"cost_source": "litellm_estimate",
"cost": _round_cost(agent_cost),
}
)
record["agents"].append(agent_record)
@@ -80,7 +84,6 @@ class LLMUsageLedger:
self._agent_usage.clear()
self._agent_metadata.clear()
self._total_cost = 0.0
self._agent_cost.clear()
if not isinstance(raw_usage, dict):
return
@@ -92,11 +95,8 @@ class LLMUsageLedger:
self._total_usage = Usage()
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):
continue
agent_id = str(raw_agent.get("agent_id") or "").strip()
@@ -116,7 +116,24 @@ class LLMUsageLedger:
if isinstance(model, str) and model:
metadata["model"] = model
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:
@@ -171,18 +188,25 @@ def _estimate_litellm_entry_cost(entry: Any, model: str) -> float | None:
if completion_details:
usage_payload["completion_tokens_details"] = completion_details
try:
from litellm import completion_cost
from litellm import completion_cost
cost = completion_cost(
completion_response={
"model": model.split("/", 1)[-1],
"usage": usage_payload,
},
model=model,
)
except Exception: # noqa: BLE001 - LiteLLM raises plain Exception for unknown model prices.
logger.debug("LiteLLM cost estimate unavailable for model %s", model, exc_info=True)
candidates = [model]
if "/" in model:
candidates.append(model.split("/", 1)[-1])
cost: Any = None
for candidate in candidates:
try:
cost = completion_cost(
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 cost if isinstance(cost, int | float) and cost >= 0 else None
Generated
+4 -4
View File
@@ -956,7 +956,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.83.7"
version = "1.88.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -972,9 +972,9 @@ dependencies = [
{ name = "tiktoken" },
{ 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 = [
{ 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]]
@@ -2035,7 +2035,7 @@ wheels = [
[[package]]
name = "strix-agent"
version = "1.0.2"
version = "1.0.3"
source = { editable = "." }
dependencies = [
{ name = "caido-sdk-client" },