diff --git a/containers/Dockerfile b/containers/Dockerfile index 9943266a..cabb6b39 100644 --- a/containers/Dockerfile +++ b/containers/Dockerfile @@ -117,6 +117,21 @@ ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium 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 +USER root +RUN set -eu; \ + { \ + 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; \ diff --git a/pyproject.toml b/pyproject.toml index 204e6ce5..77be738f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 90e1681f..95590394 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -284,7 +284,13 @@ Remember: A single well-validated high-impact vulnerability is worth more than d AGENT ISOLATION & SANDBOXING: - All agents run in the same shared Docker container for efficiency -- Each agent has its own: browser sessions, terminal sessions +- Each agent has its own terminal sessions +- Browsers are NOT per-agent by default: `agent-browser` with no `--session` is one + shared browser, so a concurrent agent's navigation invalidates your page and refs. + Pass `--session ` for any browser work of your own — then it is + yours alone. Each session is a full Chromium (~340 MB) on this shared box, so keep + one, not several, and `agent-browser --session close` when you're done with + the target; an idle browser is reclaimed automatically after 3 minutes - All agents share the same /workspace directory and proxy history - Agents can see each other's files and proxy traffic for better collaboration diff --git a/strix/config/models.py b/strix/config/models.py index e8544975..e632bb06 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -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: diff --git a/strix/core/inputs.py b/strix/core/inputs.py index ce5d7e75..d5359b5e 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -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, ) @@ -218,13 +220,15 @@ def make_model_settings( request_timeout: float | None = None, prompt_cache: bool = True, 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, + 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 @@ -247,6 +251,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, diff --git a/strix/interface/main.py b/strix/interface/main.py index ceb14c26..06966f4c 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -224,6 +224,7 @@ async def warm_up_llm(show_model_warning: bool = True) -> None: request_timeout=llm.timeout, prompt_cache=False, extra_headers=settings.dedupe.extra_headers, + has_tools=False, ) if deduper_extra: merged = {**(deduper_settings.extra_args or {}), **deduper_extra} diff --git a/strix/interface/scan_setup.py b/strix/interface/scan_setup.py index 327769a3..1e795a5c 100644 --- a/strix/interface/scan_setup.py +++ b/strix/interface/scan_setup.py @@ -78,6 +78,7 @@ async def preflight_model_connection( request_timeout=resolved_settings.llm.timeout, prompt_cache=False, extra_headers=resolved_settings.llm.extra_headers, + has_tools=False, ) await asyncio.wait_for( model.get_response( diff --git a/strix/llm/compaction.py b/strix/llm/compaction.py index 09d36454..e40caf6a 100644 --- a/strix/llm/compaction.py +++ b/strix/llm/compaction.py @@ -294,6 +294,7 @@ async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None: request_timeout=llm.timeout, prompt_cache=False, extra_headers=llm.extra_headers, + has_tools=False, ).resolve(ModelSettings(max_tokens=max_tokens)) try: response = ( diff --git a/strix/report/dedupe.py b/strix/report/dedupe.py index f848a6d6..1cc0a66a 100644 --- a/strix/report/dedupe.py +++ b/strix/report/dedupe.py @@ -62,6 +62,7 @@ def _dedupe_model_settings( # must never receive the main endpoint's credentials. A dedicated model # gets its own DEDUPE_LLM_EXTRA_HEADERS instead. extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers, + has_tools=False, ) extra = _dedupe_extra_args(dedupe) if extra: diff --git a/strix/report/pricing.py b/strix/report/pricing.py new file mode 100644 index 00000000..57c89959 --- /dev/null +++ b/strix/report/pricing.py @@ -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 diff --git a/strix/report/state.py b/strix/report/state.py index 2f3a07c8..2807ee63 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -15,6 +15,7 @@ from strix.config import codex from strix.config.loader import load_settings from strix.core.paths import run_dir_for, runtime_state_dir from strix.report.coverage import write_coverage +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 ( @@ -742,10 +743,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 diff --git a/strix/report/usage.py b/strix/report/usage.py index e3ddf494..3d6be050 100644 --- a/strix/report/usage.py +++ b/strix/report/usage.py @@ -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: diff --git a/strix/skills/tooling/agent_browser.md b/strix/skills/tooling/agent_browser.md index db074e86..a254bfaf 100644 --- a/strix/skills/tooling/agent_browser.md +++ b/strix/skills/tooling/agent_browser.md @@ -58,6 +58,26 @@ agent-browser screenshot The browser stays running across commands so these feel like a single session. Use `agent-browser close` (or `close --all`) when you're done. +The default session is **shared with every other agent in the sandbox** — if +another agent navigates it, your page and your refs are gone from under you. So +claim your own by passing `--session ` on **every** command: + +```bash +agent-browser --session recon-3 open https://example.com +agent-browser --session recon-3 snapshot -i +agent-browser --session recon-3 close # when done with the target +``` + +The examples in the rest of this skill omit `--session` to keep them readable; +keep passing yours. Each session is a separate Chromium (~340 MB) on a shared +box, so hold one rather than several, and close it when you're finished. + +A browser left idle for 3 minutes is reclaimed automatically to free memory for +the other agents; the next command relaunches it, but the page, tabs, refs and +cookies are gone. If you're authenticated and about to go do something else for a +while, save the state first (see +[Persist session across runs](#persist-session-across-runs)). + ## Reading a page ```bash @@ -307,6 +327,16 @@ agent-browser --session b fill @e1 "bob@test.com" `AGENT_BROWSER_SESSION=myapp` sets the default session for the current shell. +Use a session named after yourself for your own work — that's what keeps a +concurrent agent from navigating the page out from under you. Every session is a +separate Chromium though, so hold one at a time rather than a collection, and +close each one when its flow is finished: + +```bash +agent-browser --session a close +agent-browser --session b close +``` + ### Mock network requests ```bash @@ -368,8 +398,11 @@ agent-browser dialog dismiss # cancel ## Readiness & recovery The first `agent-browser open` in a session launches the headless-Chrome -daemon; later commands reuse it. Distinguish the two failure modes and react -differently — do **not** blindly re-run the same failing command in a loop: +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 diff --git a/tests/test_cost_tracking.py b/tests/test_cost_tracking.py index 30d4db44..6db31145 100644 --- a/tests/test_cost_tracking.py +++ b/tests/test_cost_tracking.py @@ -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"]) diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 8daeed27..e12c56c5 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -300,6 +300,16 @@ def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() -> assert settings.tool_choice == "required" +def test_make_model_settings_disables_parallel_tool_calls_by_default() -> None: + assert make_model_settings("none", model_name="gpt-4o").parallel_tool_calls is False + + +def test_make_model_settings_omits_parallel_tool_calls_without_tools() -> None: + settings = make_model_settings("none", model_name="gpt-4o", has_tools=False) + + assert settings.parallel_tool_calls is None + + def test_make_model_settings_sets_request_timeout() -> None: settings = make_model_settings( "none", @@ -381,3 +391,32 @@ def test_scan_targets_drop_empty_and_duplicate_entries() -> None: } assert build_scan_targets(config) == ["https://app.example.com"] + + +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" diff --git a/tests/test_pricing.py b/tests/test_pricing.py new file mode 100644 index 00000000..abff873d --- /dev/null +++ b/tests/test_pricing.py @@ -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() diff --git a/uv.lock b/uv.lock index e2d54d1d..523e7c64 100644 --- a/uv.lock +++ b/uv.lock @@ -2378,7 +2378,7 @@ wheels = [ [[package]] name = "strix-agent" -version = "1.5.2" +version = "1.5.3" source = { editable = "." } dependencies = [ { name = "caido-sdk-client" },