Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ npx skills add usestrix/strix
curl -sSL https://strix.ai/install | bash # install
export STRIX_LLM="openai/gpt-5.4" # any LiteLLM model id
# or a subscription: STRIX_LLM="chatgpt/gpt-5.4" (strix auth login chatgpt)
# STRIX_LLM="claude-code/claude-opus-4-8" (strix auth login claude; needs the claude CLI on PATH)
# STRIX_LLM="claude-code/claude-opus-5" (strix auth login claude; needs the claude CLI on PATH)
export LLM_API_KEY="<key>" # not needed for subscription backends
strix -n -t ./ --scan-mode quick --max-budget 10 # headless scan; always use -n
```
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ sign-in — Strix never stores your Claude credentials:
```bash
strix auth login claude # delegates to the Claude Code CLI (claude auth login)

export STRIX_LLM="claude-code/claude-opus-4-8" # claude-code/<model> runs on the subscription
export STRIX_LLM="claude-code/claude-opus-5" # claude-code/<model> runs on the subscription
strix --target ./app-directory

strix auth status # show the active sign-in
Expand Down
13 changes: 8 additions & 5 deletions docs/llm-providers/claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ unchanged.

```bash
strix auth login claude # delegates to `claude auth login`
export STRIX_LLM="claude-code/claude-opus-4-8" # claude-code/<model> runs on the subscription
export STRIX_LLM="claude-code/claude-opus-5" # claude-code/<model> runs on the subscription
strix --target ./app-directory
```

Expand All @@ -31,7 +31,7 @@ Claude Code (this is global to the CLI, not just Strix).
actionable message.
</Warning>

- Claude Code CLI **2.0.0 or newer** on the host's `PATH`.
- Claude Code CLI **2.1.220 or newer** on the host's `PATH`.
- Signed in on a **Pro or Max** plan (`claude /login`). If Claude Code is signed in on an
API key instead, Strix warns you before the scan, otherwise it would silently meter
against that key.
Expand Down Expand Up @@ -69,10 +69,13 @@ CI is better served by the **API-key path**. A CI runner cannot easily hold a si
subscription session, whereas an API key drops cleanly into a secret. Use
`anthropic/<model>` + `LLM_API_KEY` in CI unless the runner can hold a `claude` session.

## Not covered in v1.0
## Deduplication

Deduplication runs on whatever model the scan runs on, so on a `claude-code/` scan it runs on
the subscription too. Point `STRIX_DEDUPE_MODEL` at another provider if you would rather it
did not.

`STRIX_DEDUPE_MODEL` stays on an API key or another provider, deduplication does not run on
the Claude subscription for now.
## Not covered in v1.0

Screenshots and other image tool results are not passed to the model on this backend. It
bridges each turn as text, so a browser or visual tool's image is surfaced to the model as a
Expand Down
147 changes: 129 additions & 18 deletions strix/config/claude_bridge.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"""Pure translation between Strix's Agents-SDK I/O and ``claude -p`` structured output.

No subprocess, no network, this module is a function of bytes to objects, so it
tests against recorded ``claude -p`` transcripts with no live calls. See
``.artifacts/SPIKE-DECISION.md`` for the 4b protocol these functions implement.
tests against recorded ``claude -p`` transcripts with no live calls.

The contract with Claude Code (driven via ``--json-schema`` + ``--tools ""``):
Strix renders one turn's worth of history and tool descriptions into a single
Expand All @@ -19,6 +18,7 @@

import json
import logging
import math
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4

Expand Down Expand Up @@ -94,14 +94,33 @@ def build_prompt(
if tool_block:
sections.append(tool_block)
sections.append("# Conversation\n\n" + _render_input(input))
sections.append(
sections.append(_task_section(tools=bool(tool_block)))
return "\n\n".join(sections)


def _task_section(*, tools: bool) -> str:
"""Closing instruction. A tool-less turn is a plain completion, not an agent step.

Strix calls this backend two ways: agent turns, which carry tools and must
reply in the ``{text, tool_calls}`` schema, and one-shot completions with
``tools=[]`` (deduplication, preflight) whose callers parse the reply
themselves. Handing the second kind the agent framing makes the model narrate
a "step" instead of answering, which is why dedupe could never find the JSON
object it asked for.
"""
if not tools:
return (
"# Your task\n\n"
"Answer the request above directly. Reply with the answer itself and "
"nothing else, in exactly the format the instructions ask for."
)
return (
"# Your task\n\n"
"Produce the next single assistant step. Put your narration in `text`. "
"To act, list the tools to run in `tool_calls` using the exact names and "
"argument shapes above. Request only tools you need this step; leave "
"`tool_calls` empty when no action is needed or the task is done."
)
return "\n\n".join(sections)


def _render_tools(tools: list[Tool]) -> str:
Expand Down Expand Up @@ -161,6 +180,15 @@ def _render_item(item: dict[str, Any]) -> str:
return ""


# The block types the rest of Strix already treats as images: llm/compaction.py
# and interface/tui/live_view.py match on exactly these, core/sessions.py on the
# input_image the SDK's sandbox tools emit. Matching a stray ``image_url`` or
# ``source`` key instead swallows any text block that happens to carry one.
_IMAGE_BLOCK_TYPES = frozenset({"image", "image_url", "input_image", "output_image"})

_IMAGE_MARKER = "[image returned by tool, not visible to this backend]"


def _stringify(content: Any) -> str:
if content is None:
return ""
Expand All @@ -176,7 +204,7 @@ def _stringify(content: Any) -> str:
# explicit marker rather than dropping it silently, so the model
# knows a screenshot was produced and does not narrate having
# inspected one it never received.
parts.append("[image returned by tool, not visible to this backend]")
parts.append(_IMAGE_MARKER)
continue
text = block_dict.get("text") or block_dict.get("output") or block_dict.get("content")
if isinstance(text, str):
Expand All @@ -188,9 +216,7 @@ def _stringify(content: Any) -> str:


def _is_image_block(block: dict[str, Any]) -> bool:
return "image" in str(block.get("type") or "").lower() or bool(
block.get("image_url") or block.get("source")
)
return str(block.get("type") or "").lower() in _IMAGE_BLOCK_TYPES


# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -284,45 +310,130 @@ def _structured_payload(result: dict[str, Any]) -> dict[str, Any]:
parsed = json.loads(raw)
except json.JSONDecodeError:
return {"text": raw, "tool_calls": []}
if isinstance(parsed, dict):
# Only a reply shaped like RESULT_SCHEMA is our envelope. A tool-less turn
# returns the caller's own answer, which is frequently a JSON object of its
# own (dedupe asks for one); treating that as the envelope would read its
# missing "text" key as an empty response and discard the answer.
if isinstance(parsed, dict) and ("text" in parsed or "tool_calls" in parsed):
return cast("dict[str, Any]", parsed)
return {"text": raw, "tool_calls": []}
return {"text": "", "tool_calls": []}


def _strip_namespace(name: str) -> str:
# If a future 4a path surfaces MCP-prefixed names (mcp__strix__shell), the run
# loop only knows the bare tool name it registered.
# If an MCP-based transport ever surfaces prefixed names (mcp__strix__shell),
# the run loop only knows the bare tool name it registered.
if name.startswith("mcp__"):
return name.rsplit("__", 1)[-1]
return name


# Phrases Claude Code uses when the account may not run inference on the plan at
# all -- an org policy, a plan change, or revoked entitlement. Observed verbatim:
# "Your organization has disabled Claude subscription access for Claude Code -
# Use an Anthropic API key instead, or ask your admin to enable access".
_ENTITLEMENT_MARKERS = (
"subscription access for claude code",
"disabled claude subscription access",
"use an anthropic api key instead",
)


def _error_status(result: dict[str, Any]) -> int | None:
explicit = result.get("api_error_status")
if isinstance(explicit, int):
return explicit
haystack = f"{result.get('result', '')} {result.get('subtype', '')}".lower()
if any(marker in haystack for marker in _ENTITLEMENT_MARKERS):
# 403, so the run stops instead of retrying. These arrive with no
# api_error_status, and an untagged error hits the statusless fallback --
# five attempts with 2s..90s backoff, per turn, per agent, for something
# a second attempt cannot clear.
return 403
if "429" in haystack or "rate limit" in haystack or "rate_limit" in haystack:
return 429
if "overloaded" in haystack or "529" in haystack:
return 529
return None


def _finite_number(value: Any) -> float | None:
"""``value`` as a float when it is a real, finite number, else None.

Everything here is decoded from CLI stdout, and ``json.loads`` accepts the
non-standard ``Infinity``/``NaN`` literals, so a malformed field would
otherwise reach the accounting layer as an unusable float. ``bool`` is
excluded explicitly because it is an ``int`` subclass.
"""
if isinstance(value, bool) or not isinstance(value, int | float):
return None
number = float(value)
return number if math.isfinite(number) else None


def _token_count(data: dict[str, Any], key: str) -> int:
"""Non-negative token count for ``key``, tolerating a malformed payload.

A field that is missing, null, or not a finite number degrades to zero: a
usage block Strix cannot read is a reporting problem, not a reason to fail an
agent turn that already succeeded. A value that is present but unreadable is
logged, so a wire-format change shows up as something other than silence.
"""
raw = data.get(key)
number = _finite_number(raw)
if number is None:
if raw is not None:
logger.debug("unreadable claude -p usage field %s: %r", key, raw)
return 0
return max(0, int(number))


def _thinking_tokens(data: dict[str, Any]) -> int:
"""Extended-thinking tokens, which Anthropic nests under output_tokens_details."""
details = data.get("output_tokens_details")
if not isinstance(details, dict):
return 0
return _token_count(cast("dict[str, Any]", details), "thinking_tokens")


def _decode_usage(raw: Any) -> Usage:
data: dict[str, Any] = cast("dict[str, Any]", raw) if isinstance(raw, dict) else {}
input_tokens = int(data.get("input_tokens") or 0)
output_tokens = int(data.get("output_tokens") or 0)
cached = int(data.get("cache_read_input_tokens") or 0)
cache_write = int(data.get("cache_creation_input_tokens") or 0)
total = data.get("total_tokens")
cached = _token_count(data, "cache_read_input_tokens")
cache_write = _token_count(data, "cache_creation_input_tokens")
# Anthropic reports input_tokens EXCLUDING both cache counters. Every other
# Strix route normalizes through LiteLLM, whose Anthropic transformation does
# `prompt_tokens += cache_creation_input_tokens + cache_read_input_tokens`, so
# reporting the bare number here undercounts a cache-heavy turn by orders of
# magnitude (a real turn measured 143 tokens against an actual 7396) and
# starves the budget guard on a metered session.
input_tokens = _token_count(data, "input_tokens") + cached + cache_write
output_tokens = _token_count(data, "output_tokens")
return Usage(
requests=1,
input_tokens=input_tokens,
input_tokens_details=InputTokensDetails(
cached_tokens=cached, cache_write_tokens=cache_write
),
output_tokens=output_tokens,
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
total_tokens=int(total) if isinstance(total, int) else input_tokens + output_tokens,
output_tokens_details=OutputTokensDetails(reasoning_tokens=_thinking_tokens(data)),
# Derived, never read back from the payload: a `total_tokens` the CLI
# supplied would carry Anthropic's cache-excluding semantics and reopen
# the same undercount.
total_tokens=input_tokens + output_tokens,
)


def result_cost(result: dict[str, Any]) -> float | None:
"""The dollar cost Claude Code computed for this turn, or None.

Authoritative in a way a local estimate is not: the CLI prices every model the
turn touched (it may dispatch a cheaper side model of its own) at that model's
real rate, whereas Strix would have to guess a single rate from a
``claude-code/<slug>`` name LiteLLM does not carry a first-party price for.

On a subscription the ledger discards this; on an API-key session it is the
charge the budget guard has to see, so an unreadable value must read as
"nothing to record" rather than reach the guard.
"""
cost = _finite_number(result.get("total_cost_usd"))
return cost if cost is not None and cost > 0 else None
25 changes: 20 additions & 5 deletions strix/config/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
user's installed Claude Code binary in non-interactive mode (``claude -p``).
Claude Code owns auth, token refresh, and the wire protocol; this module only
locates the binary, reports its version and sign-in state, and parses the
``claude-code/<model>`` STRIX_LLM prefix. There is deliberately no OAuth here,
that is the whole point of Option B (see ``.artifacts/DESIGN.md``).
``claude-code/<model>`` STRIX_LLM prefix. There is deliberately no OAuth here:
not lifting the token out of the user's credentials file is the whole point.
"""

from __future__ import annotations
Expand All @@ -27,9 +27,24 @@
PROVIDER = "claude-code"
SUBSCRIPTION_PREFIX = "claude-code/"

# The stream-json result schema this backend relies on (structured_output,
# api_error_status) has been stable since Claude Code 2.0.
MIN_CLAUDE_VERSION = (2, 0, 0)
# Everything this backend drives has to exist in the installed CLI, and 2.0 is far
# too low a bar. Checked against the published npm bundles:
#
# 2.0.0 no --json-schema, no --effort, no --no-session-persistence,
# no --disable-slash-commands, no api_error_status
# 2.0.45 --json-schema appears
# 2.0.60 --disable-slash-commands appears
# 2.0.77 --no-session-persistence appears
# 2.1.100 api_error_status still absent (last release shipping a readable
# cli.js bundle; later ones ship a downloaded binary)
# 2.1.220 verified end to end on Windows, 2.1.239 on Linux
#
# api_error_status is what the retry policy classifies a 429/529 on, so a CLI
# without it degrades every rate limit into an unclassified error. The floor is
# therefore the lowest release actually verified to carry the whole contract;
# it is conservative by construction, since the exact release that added
# api_error_status is not visible in the published artifacts.
MIN_CLAUDE_VERSION = (2, 1, 220)

_PROBE_TIMEOUT_S = 8

Expand Down
Loading