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:
co-authored by
spindle79
Claude Sonnet 4.6
parent
c4c2205658
commit
258d2600cd
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user