Compare commits

..
14 changed files with 320 additions and 171 deletions
+17 -31
View File
@@ -58,9 +58,7 @@ RUN apt-get update && \
libcap2-bin \
gdb \
libnss3-tools \
chromium fonts-liberation fonts-noto-core fonts-noto-color-emoji \
xvfb x11-utils xdotool dbus-x11 \
imagemagick
chromium fonts-liberation
RUN setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip $(which nmap)
@@ -116,37 +114,25 @@ RUN npm install -g retire@latest && \
ln -sf ast-grep /home/pentester/.npm-global/lib/node_modules/@ast-grep/cli/sg
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
# Must match the installed Chromium major; the build asserts it below.
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
# AGENT_BROWSER_ARGS is comma-separated, so no flag value may contain a comma
# (window geometry comes from the virtual display instead).
ENV AGENT_BROWSER_ARGS="--disable-blink-features=AutomationControlled,--no-first-run,--no-default-browser-check,--lang=en-US,--password-store=basic,--use-mock-keychain,--disable-dev-shm-usage"
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
ENV AGENT_BROWSER_ARGS="--disable-blink-features=AutomationControlled,--no-first-run,--no-default-browser-check,--lang=en-US"
ENV AGENT_BROWSER_SCREENSHOT_DIR=/workspace/.agent-browser-screenshots
ENV AGENT_BROWSER_IDLE_TIMEOUT_MS=180000
RUN /home/pentester/.npm-global/bin/agent-browser doctor --offline --quick
# `agent-browser --headed` exits 0 even when Chrome fails to launch, so assert
# headed mode here. `--no-sandbox` is build-only: buildkit has no unprivileged
# user namespaces. The stale X lock/socket must go or the runtime Xvfb refuses.
USER root
RUN set -eu; \
chrome_major="$(chromium --version | grep -oE '[0-9]+' | head -1)"; \
printf '%s' "${AGENT_BROWSER_USER_AGENT}" | grep -q "Chrome/${chrome_major}\." || { \
echo "AGENT_BROWSER_USER_AGENT disagrees with installed Chromium ${chrome_major}"; exit 1; \
}; \
Xvfb :99 -screen 0 1280x800x24 -nolisten tcp >/tmp/xvfb-build.log 2>&1 & \
xvfb_pid=$!; \
for _ in $(seq 1 20); do xdpyinfo -display :99 >/dev/null 2>&1 && break; sleep 0.5; done; \
xdpyinfo -display :99 >/dev/null; \
DISPLAY=:99 agent-browser --headed --args "${AGENT_BROWSER_ARGS},--no-sandbox" \
open about:blank >/tmp/headed-check.log 2>&1 || true; \
if grep -qi 'Missing X server\|exited early' /tmp/headed-check.log; then \
echo "headed browser check failed:"; cat /tmp/headed-check.log; exit 1; \
fi; \
DISPLAY=:99 xwininfo -root -children | grep -qi chromium; \
agent-browser close >/dev/null 2>&1 || true; \
kill "${xvfb_pid}" 2>/dev/null || true; \
wait "${xvfb_pid}" 2>/dev/null || true; \
rm -rf /tmp/.X99-lock /tmp/.X11-unix/X99 /tmp/xvfb-build.log /tmp/headed-check.log
{ \
for var in AGENT_BROWSER_EXECUTABLE_PATH AGENT_BROWSER_USER_AGENT \
AGENT_BROWSER_ARGS AGENT_BROWSER_SCREENSHOT_DIR \
AGENT_BROWSER_IDLE_TIMEOUT_MS; do \
eval "value=\${$var}"; \
printf 'export %s="${%s:-%s}"\n' "$var" "$var" "$value"; \
done; \
} > /tmp/agent-browser.sh; \
install -m 0644 /tmp/agent-browser.sh /etc/profile.d/agent-browser.sh; \
rm /tmp/agent-browser.sh; \
env -i bash -lc 'test "${AGENT_BROWSER_IDLE_TIMEOUT_MS}" = "180000"'
USER pentester
RUN /home/pentester/.npm-global/bin/agent-browser doctor --offline --quick
RUN set -eux; \
TS_PARSER_DIR="/home/pentester/.tree-sitter/parsers"; \
-40
View File
@@ -117,46 +117,6 @@ echo ". /etc/profile.d/proxy.sh" >> ~/.zshrc
echo "✅ System-wide proxy configuration complete"
# A virtual display so the agent can fall back to headed Chrome when a target
# rejects headless; without it headed mode dies but still exits 0.
DISPLAY_NUM="${STRIX_DISPLAY_NUM:-99}"
DISPLAY_GEOMETRY="${STRIX_DISPLAY_GEOMETRY:-1280x800x24}"
if ! xdpyinfo -display ":${DISPLAY_NUM}" >/dev/null 2>&1; then
# Nothing is answering, so a leftover lock/socket is stale; Xvfb refuses to
# start with one present.
rm -f "/tmp/.X${DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${DISPLAY_NUM}" 2>/dev/null || true
Xvfb ":${DISPLAY_NUM}" -screen 0 "${DISPLAY_GEOMETRY}" -nolisten tcp \
> /tmp/xvfb.log 2>&1 &
for _ in $(seq 1 20); do
xdpyinfo -display ":${DISPLAY_NUM}" >/dev/null 2>&1 && break
sleep 0.5
done
fi
if xdpyinfo -display ":${DISPLAY_NUM}" >/dev/null 2>&1; then
echo "✅ Virtual display :${DISPLAY_NUM} ready (${DISPLAY_GEOMETRY})"
else
echo "⚠️ Xvfb failed to start; headed browsing is unavailable. Xvfb log:"
cat /tmp/xvfb.log 2>/dev/null || echo "(no log available)"
fi
# Best-effort session bus: without it headed Chrome spews dbus errors that read
# like fatal failures in tool output.
if [ ! -S /run/dbus/system_bus_socket ]; then
sudo mkdir -p /run/dbus
sudo dbus-daemon --system --fork > /tmp/dbus.log 2>&1 || true
fi
cat << EOF | sudo tee /etc/profile.d/browser.sh
export DISPLAY=:${DISPLAY_NUM}
EOF
echo ". /etc/profile.d/browser.sh" >> ~/.bashrc
echo ". /etc/profile.d/browser.sh" >> ~/.zshrc
. /etc/profile.d/browser.sh
echo "Adding CA to browser trust store..."
sudo -u pentester mkdir -p /home/pentester/.pki/nssdb
sudo -u pentester certutil -N -d sql:/home/pentester/.pki/nssdb --empty-password
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "strix-agent"
version = "1.5.2"
version = "1.5.3"
description = "Open-source AI Hackers for your apps"
readme = "README.md"
license = "Apache-2.0"
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict",
"enabledManagers": [
"npm",
"github-actions",
"dockerfile",
"pep621"
],
"packageRules": [
{
"groupName": "non-major dependencies",
"matchUpdateTypes": [
"minor",
"patch"
]
},
{
"matchManagers": [
"github-actions",
"dockerfile"
],
"pinDigests": true
}
]
}
+9 -5
View File
@@ -652,27 +652,31 @@ def _install_openrouter_stream_cost_capture() -> None:
litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc]
_OPENROUTER_ATTRIBUTION_HEADERS = {
OPENROUTER_ATTRIBUTION_HEADERS = {
"HTTP-Referer": "https://strix.ai",
"X-Title": "Strix",
"X-OpenRouter-Categories": "cli-agent",
}
def is_openrouter_model(model_name: str | None) -> bool:
return bool(model_name) and "openrouter/" in (model_name or "").strip().lower()
def _configure_openrouter_attribution(model_name: str | None) -> None:
import litellm
current: object = litellm.headers
existing: dict[str, str] = current if isinstance(current, dict) else {}
if not model_name or "openrouter/" not in model_name.strip().lower():
if any(key in existing for key in _OPENROUTER_ATTRIBUTION_HEADERS):
if not is_openrouter_model(model_name):
if any(key in existing for key in OPENROUTER_ATTRIBUTION_HEADERS):
remaining = {
k: v for k, v in existing.items() if k not in _OPENROUTER_ATTRIBUTION_HEADERS
k: v for k, v in existing.items() if k not in OPENROUTER_ATTRIBUTION_HEADERS
}
litellm.headers = remaining or None # type: ignore[assignment]
return
litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
litellm.headers = {**existing, **OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
def _configure_extra_headers(llm: LlmSettings) -> None:
+15 -1
View File
@@ -10,10 +10,12 @@ from openai.types.shared import Reasoning
from strix.config.models import (
DEFAULT_MODEL_RETRY,
OPENROUTER_ATTRIBUTION_HEADERS,
bedrock_route_supports_prompt_caching,
is_bedrock_route,
is_claude_model,
is_known_openai_bare_model,
is_openrouter_model,
model_supports_reasoning,
request_timeout_extra_args,
)
@@ -203,12 +205,13 @@ def make_model_settings(
extra_headers: dict[str, str] | None = None,
has_tools: bool = True,
) -> ModelSettings:
headers = _request_headers(model_name, extra_headers)
model_settings = ModelSettings(
parallel_tool_calls=False if has_tools else None,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(request_timeout),
extra_headers=dict(extra_headers) if extra_headers else None,
extra_headers=headers,
)
if (
reasoning_effort is not None
@@ -231,6 +234,17 @@ def make_model_settings(
return model_settings
def _request_headers(
model_name: str, extra_headers: dict[str, str] | None
) -> dict[str, str] | None:
headers: dict[str, str] = {}
if is_openrouter_model(model_name):
headers.update(OPENROUTER_ATTRIBUTION_HEADERS)
if extra_headers:
headers.update(extra_headers)
return headers or None
def _reasoning_settings(
effort: ReasoningEffort,
extra_args: dict[str, Any] | None,
+54
View File
@@ -0,0 +1,54 @@
"""LiteLLM model-name resolution for local cost estimates."""
from __future__ import annotations
from functools import lru_cache
from typing import Any, cast
@lru_cache(maxsize=512)
def resolve_litellm_model(model: str) -> str | None:
"""Return a provider-qualified model name that LiteLLM can price."""
try:
import litellm
normalized = model.strip()
for prefix in ("litellm/", "any-llm/", "openai/"):
if normalized.startswith(prefix):
normalized = normalized.removeprefix(prefix)
break
if not normalized:
return None
model_cost = cast(
"dict[str, dict[str, Any]]",
getattr(litellm, "model_cost"), # noqa: B009
)
bare_entry = model_cost.get(normalized)
if "/" not in normalized and isinstance(bare_entry, dict):
provider = bare_entry.get("litellm_provider")
if isinstance(provider, str) and provider:
return f"{provider}/{normalized}"
if "/" in normalized and isinstance(bare_entry, dict):
return normalized
names = [normalized]
if "/" in normalized:
names.append(normalized.rsplit("/", 1)[-1])
for name in names:
matches = sorted(key for key in model_cost if key.endswith(f"/{name}"))
if not matches:
continue
prices = {
(
model_cost[key].get("input_cost_per_token"),
model_cost[key].get("output_cost_per_token"),
)
for key in matches
if isinstance(model_cost.get(key), dict)
}
if len(matches) == 1 or len(prices) == 1:
return matches[0]
return None # noqa: TRY300
except Exception: # noqa: BLE001
return None
+6 -2
View File
@@ -14,6 +14,7 @@ from agents.usage import Usage
from strix.config import codex
from strix.config.loader import load_settings
from strix.core.paths import run_dir_for
from strix.report.pricing import resolve_litellm_model
from strix.report.sarif import write_sarif
from strix.report.usage import LLMUsageLedger
from strix.report.writer import (
@@ -696,10 +697,13 @@ def _estimate_response_cost(kwargs: Any, completion_response: Any) -> float | No
candidates.append(model.rsplit("/", 1)[-1])
for candidate in candidates:
resolved = resolve_litellm_model(candidate)
if not resolved:
continue
try:
value = completion_cost(
completion_response={"model": candidate, "usage": usage_payload},
model=candidate,
completion_response={"model": resolved, "usage": usage_payload},
model=resolved,
)
except Exception: # nosec B112 # noqa: BLE001, S112
continue
+30 -29
View File
@@ -7,6 +7,8 @@ from typing import Any
from agents.usage import Usage, deserialize_usage, serialize_usage
from strix.report.pricing import resolve_litellm_model
logger = logging.getLogger(__name__)
@@ -18,7 +20,9 @@ class LLMUsageLedger:
self._total_usage = Usage()
self._agent_usage: dict[str, Usage] = {}
self._agent_metadata: dict[str, dict[str, str]] = {}
self._total_cost = 0.0
self._observed_cost = 0.0
self._estimated_cost = 0.0
self._has_observed_cost = False
# When True, tokens are still tracked but cost stays $0 — the run is on a
# model subscription, so there is no metered per-token charge to report.
self.zero_cost = False
@@ -44,10 +48,10 @@ class LLMUsageLedger:
if model:
metadata["model"] = model
if not self.zero_cost and not _is_litellm_routed(model):
if not self.zero_cost:
estimated = _estimate_litellm_cost(usage, model)
if estimated:
self._total_cost += estimated
self._estimated_cost += estimated
return True
@@ -55,15 +59,18 @@ class LLMUsageLedger:
if self.zero_cost:
return
if isinstance(cost, int | float) and cost > 0:
self._total_cost += float(cost)
self._observed_cost += float(cost)
self._has_observed_cost = True
@property
def total_cost(self) -> float:
return _round_cost(self._total_cost)
if self.zero_cost:
return 0.0
return _round_cost(self._observed_cost if self._has_observed_cost else self._estimated_cost)
def to_record(self) -> dict[str, Any]:
record = serialize_usage(self._total_usage)
record["cost"] = _round_cost(self._total_cost)
record["cost"] = self.total_cost
record["agents"] = []
agent_tokens = {aid: _resolve_total_tokens(u) for aid, u in self._agent_usage.items()}
@@ -72,7 +79,7 @@ class LLMUsageLedger:
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
self.total_cost * (agent_tokens[agent_id] / total_tokens) if total_tokens else 0.0
)
agent_record = serialize_usage(usage)
@@ -92,7 +99,9 @@ class LLMUsageLedger:
self._total_usage = Usage()
self._agent_usage.clear()
self._agent_metadata.clear()
self._total_cost = 0.0
self._observed_cost = 0.0
self._estimated_cost = 0.0
self._has_observed_cost = False
if not isinstance(raw_usage, dict):
return
@@ -103,7 +112,9 @@ class LLMUsageLedger:
logger.exception("Failed to hydrate aggregate llm_usage from run.json")
self._total_usage = Usage()
self._total_cost = _float_or_zero(raw_usage.get("cost"))
persisted_cost = _float_or_zero(raw_usage.get("cost"))
self._observed_cost = persisted_cost
self._estimated_cost = persisted_cost
for raw_agent in raw_usage.get("agents") or []:
if not isinstance(raw_agent, dict):
@@ -136,15 +147,6 @@ def _resolve_total_tokens(usage: Usage) -> int:
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:
return bool(
usage.requests
@@ -201,24 +203,23 @@ def _estimate_litellm_entry_cost(entry: Any, model: str) -> float | None:
candidates = [model]
if "/" in model:
candidates.append(model.split("/", 1)[-1])
candidates.append(model.rsplit("/", 1)[-1])
cost: Any = None
for candidate in candidates:
resolved = resolve_litellm_model(candidate)
if not resolved:
continue
try:
cost = completion_cost(
completion_response={"model": candidate, "usage": usage_payload},
model=model,
completion_response={"model": resolved, "usage": usage_payload},
model=resolved,
)
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
if cost > 0:
return float(cost)
logger.debug("LiteLLM cost estimate unavailable for model %s", model)
return None
def _litellm_model_name(model: str | None) -> str | None:
+7 -60
View File
@@ -189,59 +189,6 @@ After any page-changing action, pick one:
Avoid bare `wait 2000` except when debugging — it makes scripts slow and
flaky. Timeouts default to 25 seconds.
## Bot protection (read before fighting a login)
Many targets sit behind a bot check (Cloudflare Turnstile, hCaptcha, Datadome).
You will see it in a snapshot as a challenge iframe plus inputs that go
`[disabled]` when you submit:
```
- textbox "Email address" [disabled, ref=e18]: user@example.com
- button "Continue with email" [disabled, ref=e12]
- Iframe "Widget containing a Cloudflare security challenge" [ref=e15]
- checkbox "Verify you are human" [checked=false, ref=e22]
```
`checked=false` that never flips means the challenge is refusing you, not that
the click missed. Headless Chrome is itself one of the strongest signals these
systems key on, so switch to a real browser window instead of retrying:
```bash
agent-browser close --all # a running daemon makes --headed a no-op
export AGENT_BROWSER_HEADED=1 # applies to every later command
agent-browser open https://target.tld/login
agent-browser get url # confirm it actually launched
```
The sandbox provides a virtual display and `DISPLAY` is already exported, so this
works with no setup. Three traps:
- **Set the env var, don't just pass `--headed` to `open`.** The flag is
per-invocation: the next bare `agent-browser snapshot` tries to start a
*headless* daemon and dies with `Multiple targets are not supported in headless
mode`. Export `AGENT_BROWSER_HEADED=1` (or pass `--headed` to every command).
- **`--headed` is ignored when a daemon is already running** — it prints
`⚠ --headed ignored: daemon already running`. Always `close --all` first.
- **A failed launch still exits 0.** Read the output text: `✗ Chrome exited
early` or `Missing X server` means you are not headed, whatever the exit code
says. Confirm with `agent-browser get url` before concluding anything.
Then behave like a person rather than a script:
- Drive real input events — `click`, `hover`, `keyboard type` — never `eval` with
`element.value = ...`. Assigning `value` directly leaves React-controlled
inputs internally empty, so the form submits blank or stays disabled.
- `focus` the field, then `keyboard type "text"` when `fill` appears to work but
the app doesn't react.
- Click the challenge checkbox by its ref inside the iframe, then
`wait --text` / `wait --url` for the *result*; don't re-click while it verifies.
- Save the session once you're through (`state save`, or `--session-name`) so a
browser restart doesn't send you back to the challenge.
If the challenge still refuses after a couple of honest attempts, stop. Report
that the target is gated and hand back a bounded result — burning your whole
window on one login costs more coverage than the login was worth.
## Common workflows
### Log in
@@ -450,12 +397,12 @@ agent-browser dialog dismiss # cancel
## Readiness & recovery
The first `agent-browser open` in a session launches the Chrome daemon (headless
unless you pass `--headed`); later commands reuse it. A daemon left idle for 3
minutes shuts itself down to free memory for the other agents, so an `open` after
a long gap is a fresh browser rather than a resumed one — expect to re-navigate,
and re-`state load` if you were logged in. Distinguish the failure modes and react
differently — do **not** blindly re-run the same failing command in a loop:
The first `agent-browser open` in a session launches the headless-Chrome
daemon; later commands reuse it. A daemon left idle for 3 minutes shuts itself
down to free memory for the other agents, so an `open` after a long gap is a
fresh browser rather than a resumed one — expect to re-navigate, and re-`state
load` if you were logged in. Distinguish the failure modes and react differently
— do **not** blindly re-run the same failing command in a loop:
- **Daemon / connection failure** (`Failed to connect`, `connection refused`,
socket missing, `browser not running`): the daemon isn't up or has died. Run
@@ -543,7 +490,7 @@ and [references/authentication.md](references/authentication.md).
```bash
--session <name> # isolated browser session
--json # JSON output (for machine parsing)
--headed # real browser window (default is headless); see "Bot protection"
--headed # show the window (default is headless)
--auto-connect # connect to an already-running Chrome
--cdp <port> # connect to a specific CDP port
--profile <name|path> # use a Chrome profile (login state survives)
+1 -1
View File
@@ -143,7 +143,7 @@ def test_cost_callback_estimates_cost_with_bare_model_fallback() -> None:
}
def fake_completion_cost(**kwargs: object) -> float:
if kwargs["model"] == "gpt-4o-mini":
if kwargs["model"] == "openai/gpt-4o-mini":
return 0.025
raise ValueError(kwargs["model"])
+29
View File
@@ -361,3 +361,32 @@ def test_make_model_settings_timeout_survives_reasoning_resolve() -> None:
assert settings.extra_args is not None
assert settings.extra_args["timeout"] == 120.0
def test_openrouter_attribution_rides_on_the_request_headers() -> None:
# litellm.headers is ignored once a request carries any header of its own,
# so the attribution must be part of the per-request headers.
headers = make_model_settings(
None, model_name="openrouter/anthropic/claude-sonnet-4-5"
).extra_headers
assert headers == {
"HTTP-Referer": "https://strix.ai",
"X-Title": "Strix",
"X-OpenRouter-Categories": "cli-agent",
}
def test_openrouter_attribution_absent_for_other_providers() -> None:
assert make_model_settings(None, model_name="anthropic/claude-sonnet-4-5").extra_headers is None
def test_user_headers_override_openrouter_attribution() -> None:
headers = make_model_settings(
None,
model_name="openrouter/anthropic/claude-sonnet-4-5",
extra_headers={"X-Title": "Custom", "X-Tenant": "acme"},
).extra_headers
assert headers is not None
assert headers["X-Title"] == "Custom"
assert headers["X-Tenant"] == "acme"
assert headers["HTTP-Referer"] == "https://strix.ai"
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from unittest.mock import patch
import litellm
from agents.usage import Usage
from strix.report.pricing import resolve_litellm_model
from strix.report.usage import LLMUsageLedger
def test_resolves_common_bare_model_names() -> None:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("deepseek-v4-flash") == "deepseek/deepseek-v4-flash"
assert resolve_litellm_model("openai/deepseek-v4-flash") == "deepseek/deepseek-v4-flash"
assert resolve_litellm_model("grok-4.5") == "xai/grok-4.5"
assert resolve_litellm_model("MiniMax-M3") == "minimax/MiniMax-M3"
def test_resolver_returns_none_for_unresolvable_model() -> None:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("provider/not-a-real-model") is None
def test_ledger_uses_estimate_when_routed_provider_reports_no_cost() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
with patch("litellm.completion_cost", return_value=0.42):
ledger.record(agent_id="a", usage=usage, model="openai/deepseek-v4-flash")
assert ledger.total_cost == 0.42
def test_ledger_prefers_observed_cost_over_estimate() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
with patch("litellm.completion_cost", return_value=0.42):
ledger.record(agent_id="a", usage=usage, model="openai/deepseek-v4-flash")
ledger.record_observed_cost(0.17)
assert ledger.total_cost == 0.17
def test_hydrated_estimate_continues_accumulating_new_estimates() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
ledger.hydrate({"cost": 0.42})
with patch("litellm.completion_cost", return_value=0.17):
ledger.record(agent_id="a", usage=usage, model="openai/deepseek-v4-flash")
assert ledger.total_cost == 0.59
def test_zero_cost_disables_both_observed_and_estimated_costs() -> None:
usage = Usage()
usage.requests = 1
usage.input_tokens = 1000
usage.output_tokens = 200
usage.total_tokens = 1200
ledger = LLMUsageLedger()
ledger.zero_cost = True
with patch("litellm.completion_cost", return_value=0.42) as estimate:
ledger.record(agent_id="a", usage=usage, model="deepseek-v4-flash")
ledger.record_observed_cost(1.0)
estimate.assert_not_called()
assert ledger.total_cost == 0.0
def test_resolver_uses_provider_when_bare_entry_has_one() -> None:
original = litellm.model_cost
litellm.model_cost = {
"example": {
"litellm_provider": "example-provider",
"input_cost_per_token": 1.0,
"output_cost_per_token": 2.0,
}
}
try:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("example") == "example-provider/example"
finally:
litellm.model_cost = original
resolve_litellm_model.cache_clear()
def test_resolver_does_not_guess_between_differently_priced_providers() -> None:
original = litellm.model_cost
litellm.model_cost = {
"provider-a/example": {
"input_cost_per_token": 1.0,
"output_cost_per_token": 2.0,
},
"provider-b/example": {
"input_cost_per_token": 3.0,
"output_cost_per_token": 4.0,
},
}
try:
resolve_litellm_model.cache_clear()
assert resolve_litellm_model("example") is None
finally:
litellm.model_cost = original
resolve_litellm_model.cache_clear()
Generated
+1 -1
View File
@@ -2378,7 +2378,7 @@ wheels = [
[[package]]
name = "strix-agent"
version = "1.5.2"
version = "1.5.3"
source = { editable = "." }
dependencies = [
{ name = "caido-sdk-client" },