feat: add --backend claude-cli (routes through Claude Code, no API key needed) (#855)

Co-Authored-By: spindle79 <spindle79@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-13 23:53:00 +01:00
co-authored by spindle79 Claude Sonnet 4.6
parent c4c2205658
commit 258d2600cd
3 changed files with 230 additions and 2 deletions
+10
View File
@@ -2447,6 +2447,16 @@ def main() -> None:
or os.environ.get("AWS_DEFAULT_REGION")
or os.environ.get("AWS_ACCESS_KEY_ID")
)
elif backend == "claude-cli":
import shutil as _shutil
allow_no_key = _shutil.which("claude") is not None
if not allow_no_key:
print(
"error: backend 'claude-cli' requires the `claude` CLI on $PATH "
"(install Claude Code and run `claude` once to authenticate).",
file=sys.stderr,
)
sys.exit(1)
if not allow_no_key:
print(
f"error: backend '{backend}' requires {_format_backend_env_keys(backend)} to be set.",
+103 -2
View File
@@ -94,6 +94,16 @@ BACKENDS: dict[str, dict] = {
"temperature": 0,
"max_tokens": 16384,
},
"claude-cli": {
# Routes through the locally-installed `claude` CLI (Claude Code) using
# `-p --output-format json`. Authenticates via the user's existing
# Pro/Max subscription instead of a separate ANTHROPIC_API_KEY — costs
# are billed to the plan, not pay-as-you-go API credit.
"default_model": "claude-code-plan",
"pricing": {"input": 0.0, "output": 0.0},
"temperature": 0,
"max_tokens": 16384,
},
}
@@ -397,6 +407,71 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int =
return result
def _call_claude_cli(user_message: str, max_tokens: int = 8192) -> dict:
"""Call Claude via the locally-installed Claude Code CLI (`claude -p`).
Routes through the user's Claude Code subscription auth instead of a separate
ANTHROPIC_API_KEY. Useful for Pro/Max subscribers who don't want to provision
a pay-as-you-go API key just to run graphify's semantic pass.
"""
import shutil
import subprocess
if shutil.which("claude") is None:
raise RuntimeError(
"Claude Code CLI not found on $PATH. Install from "
"https://claude.ai/code and run `claude` once to authenticate."
)
proc = subprocess.run(
[
"claude", "-p",
"--output-format", "json",
"--no-session-persistence",
"--append-system-prompt", _EXTRACTION_SYSTEM,
],
input=user_message,
capture_output=True,
text=True,
timeout=600,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(
f"claude -p exited {proc.returncode}: {proc.stderr.strip()[:500]}"
)
try:
envelope = json.loads(proc.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"claude -p produced unparseable JSON envelope: {exc}; "
f"first 500 chars of stdout: {proc.stdout[:500]!r}"
) from exc
raw_content = envelope.get("result", "")
result = _parse_llm_json(raw_content or "{}")
usage = envelope.get("usage") or {}
result["input_tokens"] = (
int(usage.get("input_tokens", 0) or 0)
+ int(usage.get("cache_read_input_tokens", 0) or 0)
+ int(usage.get("cache_creation_input_tokens", 0) or 0)
)
result["output_tokens"] = int(usage.get("output_tokens", 0) or 0)
model_usage = envelope.get("modelUsage") or {}
result["model"] = next(iter(model_usage), "claude-code-plan")
stop_reason = envelope.get("stop_reason", "")
result["finish_reason"] = "length" if stop_reason == "max_tokens" else "stop"
if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length":
print(
"[graphify] claude-cli returned a hollow response; treating as "
"truncation so adaptive retry can bisect the chunk.",
file=sys.stderr,
)
result["finish_reason"] = "length"
return result
def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192) -> dict:
"""Call AWS Bedrock via boto3 Converse API using the standard AWS credential chain."""
try:
@@ -471,7 +546,7 @@ def extract_files_direct(
file=sys.stderr,
)
key = "ollama"
if not key and backend != "bedrock":
if not key and backend not in ("bedrock", "claude-cli"):
raise ValueError(
f"No API key for backend '{backend}'. "
f"Set {_format_backend_env_keys(backend)} or pass api_key=."
@@ -482,6 +557,8 @@ def extract_files_direct(
if backend == "claude":
return _call_claude(key, mdl, user_msg, max_tokens=max_out)
if backend == "claude-cli":
return _call_claude_cli(user_msg, max_tokens=max_out)
if backend == "bedrock":
return _call_bedrock(mdl, user_msg, max_tokens=max_out)
return _call_openai_compat(
@@ -794,6 +871,10 @@ def extract_corpus_parallel(
# responses after 3-4 chunks (#798). Force serial unless the user opts in.
if backend == "ollama" and os.environ.get("GRAPHIFY_OLLAMA_PARALLEL", "").strip() != "1":
max_concurrency = 1
# claude-cli shells out to a Claude Code session; parallel subprocesses conflict
# over session state. Force serial unless the user explicitly opts in.
if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1":
max_concurrency = 1
workers = max(1, min(max_concurrency, total))
if workers == 1:
# Avoid thread pool overhead for single-worker runs (and keep
@@ -852,7 +933,7 @@ def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str:
ollama_url = os.environ.get("OLLAMA_BASE_URL", cfg.get("base_url", ""))
_validate_ollama_base_url(ollama_url)
key = "ollama"
if not key and backend != "bedrock":
if not key and backend not in ("bedrock", "claude-cli"):
raise ValueError(
f"No API key for backend '{backend}'. Set {_format_backend_env_keys(backend)}."
)
@@ -871,6 +952,26 @@ def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str:
)
return resp.content[0].text if resp.content else ""
if backend == "claude-cli":
import shutil, subprocess
if shutil.which("claude") is None:
raise RuntimeError("Claude Code CLI not found on $PATH")
proc = subprocess.run(
["claude", "-p", "--output-format", "json", "--no-session-persistence"],
input=prompt,
capture_output=True,
text=True,
timeout=600,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(f"claude -p exited {proc.returncode}: {proc.stderr.strip()[:500]}")
try:
envelope = json.loads(proc.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"claude -p produced unparseable JSON envelope: {exc}") from exc
return envelope.get("result", "")
if backend == "bedrock":
try:
import boto3
+117
View File
@@ -0,0 +1,117 @@
"""Tests for the `claude-cli` backend (#855/#856).
Mocks subprocess.run + shutil.which so the suite runs on CI without
the `claude` binary or a live network call.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from graphify import llm
_ENVELOPE = {
"type": "result",
"subtype": "success",
"is_error": False,
"result": json.dumps({
"nodes": [
{"id": "foo_module", "label": "Foo", "file_type": "document", "source_file": "foo.md"},
{"id": "foo_greet", "label": "greet", "file_type": "code", "source_file": "foo.md"},
],
"edges": [
{"source": "foo_module", "target": "foo_greet",
"relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0},
],
"hyperedges": [],
"input_tokens": 0,
"output_tokens": 0,
}),
"stop_reason": "end_turn",
"usage": {
"input_tokens": 6,
"output_tokens": 11,
"cache_read_input_tokens": 17837,
"cache_creation_input_tokens": 30800,
},
"modelUsage": {"claude-opus-4-7[1m]": {"inputTokens": 6, "outputTokens": 11}},
}
@pytest.fixture
def fake_claude(monkeypatch):
completed = MagicMock(returncode=0, stdout=json.dumps(_ENVELOPE), stderr="")
monkeypatch.setattr(llm, "_response_is_hollow", lambda raw, parsed: False)
with patch("shutil.which", return_value="/fake/bin/claude"), \
patch("subprocess.run", return_value=completed) as run:
yield run
def test_returns_parsed_nodes_and_edges(fake_claude):
result = llm._call_claude_cli("dummy", max_tokens=8192)
assert len(result["nodes"]) == 2
assert len(result["edges"]) == 1
def test_token_accounting_includes_cache(fake_claude):
result = llm._call_claude_cli("dummy", max_tokens=8192)
assert result["input_tokens"] == 6 + 17837 + 30800
assert result["output_tokens"] == 11
assert result["model"] == "claude-opus-4-7[1m]"
assert result["finish_reason"] == "stop"
def test_finish_reason_length_on_max_tokens(monkeypatch):
envelope = dict(_ENVELOPE, stop_reason="max_tokens")
completed = MagicMock(returncode=0, stdout=json.dumps(envelope), stderr="")
monkeypatch.setattr(llm, "_response_is_hollow", lambda raw, parsed: False)
with patch("shutil.which", return_value="/fake/bin/claude"), \
patch("subprocess.run", return_value=completed):
result = llm._call_claude_cli("dummy", max_tokens=8192)
assert result["finish_reason"] == "length"
def test_raises_when_cli_missing():
with patch("shutil.which", return_value=None):
with pytest.raises(RuntimeError, match="Claude Code CLI not found"):
llm._call_claude_cli("dummy", max_tokens=8192)
def test_raises_on_nonzero_exit():
completed = MagicMock(returncode=2, stdout="", stderr="auth failed")
with patch("shutil.which", return_value="/fake/bin/claude"), \
patch("subprocess.run", return_value=completed):
with pytest.raises(RuntimeError, match="exited 2"):
llm._call_claude_cli("dummy", max_tokens=8192)
def test_raises_on_garbage_envelope():
completed = MagicMock(returncode=0, stdout="not json", stderr="")
with patch("shutil.which", return_value="/fake/bin/claude"), \
patch("subprocess.run", return_value=completed):
with pytest.raises(RuntimeError, match="unparseable JSON envelope"):
llm._call_claude_cli("dummy", max_tokens=8192)
def test_extract_files_direct_dispatches_to_claude_cli(tmp_path, fake_claude):
f = tmp_path / "foo.md"
f.write_text("# Foo\n\nThe greet() helper formats a name.\n")
result = llm.extract_files_direct(files=[f], backend="claude-cli", root=tmp_path)
assert fake_claude.called
assert len(result["nodes"]) == 2
def test_backend_registered_with_zero_cost():
assert "claude-cli" in llm.BACKENDS
pricing = llm.BACKENDS["claude-cli"]["pricing"]
assert pricing["input"] == 0.0
assert pricing["output"] == 0.0
assert llm.estimate_cost("claude-cli", 1_000_000, 1_000_000) == 0.0
def test_no_session_persistence_flag_in_subprocess(fake_claude):
llm._call_claude_cli("dummy", max_tokens=8192)
call_args = fake_claude.call_args[0][0]
assert "--no-session-persistence" in call_args