axonwork 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. axonwork-0.2.0/PKG-INFO +13 -0
  2. axonwork-0.2.0/README.md +167 -0
  3. axonwork-0.2.0/axon/__init__.py +0 -0
  4. axonwork-0.2.0/axon/api.py +83 -0
  5. axonwork-0.2.0/axon/backends/__init__.py +5 -0
  6. axonwork-0.2.0/axon/backends/base.py +23 -0
  7. axonwork-0.2.0/axon/backends/claude_cli.py +290 -0
  8. axonwork-0.2.0/axon/backends/codex_cli.py +223 -0
  9. axonwork-0.2.0/axon/backends/litellm_backend.py +51 -0
  10. axonwork-0.2.0/axon/backends/registry.py +61 -0
  11. axonwork-0.2.0/axon/cli.py +625 -0
  12. axonwork-0.2.0/axon/config.py +55 -0
  13. axonwork-0.2.0/axon/display.py +365 -0
  14. axonwork-0.2.0/axon/history.py +133 -0
  15. axonwork-0.2.0/axon/llm.py +214 -0
  16. axonwork-0.2.0/axon/log.py +44 -0
  17. axonwork-0.2.0/axon/mining.py +680 -0
  18. axonwork-0.2.0/axon/providers.py +44 -0
  19. axonwork-0.2.0/axon/session.py +26 -0
  20. axonwork-0.2.0/axon/wallet.py +45 -0
  21. axonwork-0.2.0/axonwork.egg-info/PKG-INFO +13 -0
  22. axonwork-0.2.0/axonwork.egg-info/SOURCES.txt +35 -0
  23. axonwork-0.2.0/axonwork.egg-info/dependency_links.txt +1 -0
  24. axonwork-0.2.0/axonwork.egg-info/entry_points.txt +2 -0
  25. axonwork-0.2.0/axonwork.egg-info/requires.txt +9 -0
  26. axonwork-0.2.0/axonwork.egg-info/top_level.txt +1 -0
  27. axonwork-0.2.0/pyproject.toml +22 -0
  28. axonwork-0.2.0/setup.cfg +4 -0
  29. axonwork-0.2.0/tests/test_api_integration.py +56 -0
  30. axonwork-0.2.0/tests/test_backends.py +330 -0
  31. axonwork-0.2.0/tests/test_cli.py +263 -0
  32. axonwork-0.2.0/tests/test_config.py +67 -0
  33. axonwork-0.2.0/tests/test_display.py +326 -0
  34. axonwork-0.2.0/tests/test_history.py +143 -0
  35. axonwork-0.2.0/tests/test_llm.py +111 -0
  36. axonwork-0.2.0/tests/test_session.py +40 -0
  37. axonwork-0.2.0/tests/test_wallet.py +74 -0
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: axonwork
3
+ Version: 0.2.0
4
+ Summary: Axon mining CLI for the Proof of Useful Work platform
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: typer>=0.15.0
7
+ Requires-Dist: httpx>=0.28.0
8
+ Requires-Dist: litellm>=1.60.0
9
+ Requires-Dist: rich>=13.9.0
10
+ Requires-Dist: eth-account>=0.13.0
11
+ Requires-Dist: simple-term-menu>=1.6.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest>=8.0; extra == "test"
@@ -0,0 +1,167 @@
1
+ # axon-cli
2
+
3
+ Command-line miner for the Axon USDC bounty platform. Connects to an axon-server instance, selects tasks, runs AI backends to generate solutions, submits them for evaluation, and iterates until the score improves or the task is completed.
4
+
5
+ ## What is this?
6
+
7
+ The CLI is the miner's interface to Axon. It generates an Ethereum wallet, authenticates with the server via signature, then enters a mining loop: pick a task, generate an answer via one of three backends (litellm API, Claude Code CLI, or Codex CLI), submit the answer, read the eval feedback, and try again.
8
+
9
+ ## Quick Start
10
+
11
+ ```bash
12
+ # Prerequisites: Python 3.11+, uv
13
+ uv venv && uv pip install -e .
14
+
15
+ # First-time setup (generates wallet, picks backend + LLM provider)
16
+ .venv/bin/axon onboard
17
+
18
+ # Start mining
19
+ .venv/bin/axon mine
20
+
21
+ ```
22
+
23
+ ## Commands
24
+
25
+ | Command | Description |
26
+ |---------|-------------|
27
+ | `axon onboard` | First-time setup: generate wallet, configure server + backend + LLM |
28
+ | `axon mine` | Start mining loop (interactive task selection; defaults to 5 rounds with a 10 minute hard timeout per CLI backend call) |
29
+ | `axon mine --max-rounds 10` | Limit mining to N rounds |
30
+ | `axon mine --timeout 180` | Override the hard timeout for each CLI backend call in this run |
31
+ | `axon mine --yolo` | Disable hard timeout and round limit for this run; stop manually with `Ctrl+C` |
32
+ | `axon mine -yolo` | Alias for `axon mine --yolo` |
33
+ | `axon balance` | Show USDC balance (platform + on-chain Base) |
34
+ | `axon wallet` | Show wallet address |
35
+ | `axon model` | Show or switch LLM model (interactive picker) |
36
+ | `axon model NAME` | Set model directly (e.g. `anthropic/claude-sonnet-4-20250514`) |
37
+ | `axon backend` | Show or switch mining backend (interactive picker) |
38
+ | `axon backend NAME` | Set backend directly (`auto`, `litellm`, `claude-cli`, `codex-cli`) |
39
+ | `axon stats` | Mining statistics (earned, improvements) |
40
+ | `axon tasks` | Browse open tasks |
41
+ | `axon tasks --status-filter completed` | Filter tasks by status |
42
+
43
+ ## Mining Backends
44
+
45
+ The CLI supports three backends for generating solutions. The `auto` backend (default) picks the first available in order: `claude-cli` > `codex-cli` > `litellm`.
46
+
47
+ | Backend | Description | Requirements |
48
+ |---------|-------------|--------------|
49
+ | **litellm** | Call LLM APIs (Anthropic, OpenAI, DeepSeek, Ollama) via litellm | API key in config |
50
+ | **claude-cli** | Agentic mining via Claude Code CLI (tools, search, code exec) | `claude` binary in PATH |
51
+ | **codex-cli** | Agentic mining via OpenAI Codex CLI (code exec, search) | `codex` binary in PATH |
52
+
53
+ CLI backends (`claude-cli`, `codex-cli`) manage their own API keys and model selection. The `litellm` backend uses the model and API keys configured in `~/.axon/config.json`.
54
+
55
+ ## Mining Loop
56
+
57
+ ```
58
+ ┌──────────────────────────────────────────────────────┐
59
+ │ Mining Round │
60
+ │ │
61
+ │ 1. Build prompt (task + best answer + feedback) │
62
+ │ 2. Call mining backend (litellm / claude / codex) │
63
+ │ 3. Parse <thinking> and <answer> tags │
64
+ │ 4. Submit to server │
65
+ │ 5. Server evaluates, returns score + reward │
66
+ │ 6. Update prompt with feedback │
67
+ │ 7. Repeat until threshold reached or interrupted │
68
+ │ │
69
+ │ Session auto-saved after each round. │
70
+ │ Ctrl+C to stop. Run again to resume. │
71
+ └──────────────────────────────────────────────────────┘
72
+ ```
73
+
74
+ Features during mining:
75
+ - **Live status panel** with current score, pool, earned USDC, token usage, and cost
76
+ - **Community context** -- top submissions from other miners are included in the prompt
77
+ - **Duplicate detection** -- stops after 3 consecutive identical answers
78
+ - **Rate limit handling** -- auto-waits on 429 responses
79
+ - **Command defaults** -- `axon mine` runs 5 rounds with a 10 minute hard timeout per CLI backend call; `axon mine --timeout 180` changes that timeout for the current run; `axon mine --yolo` disables both limits for the current run
80
+ - **Ctrl+O** to toggle detailed round view; arrow keys to browse history
81
+
82
+ ## Supported LLM Providers (litellm backend)
83
+
84
+ | Provider | Prefix | Example Model |
85
+ |----------|--------|---------------|
86
+ | Anthropic | `anthropic/` | `anthropic/claude-sonnet-4-20250514` |
87
+ | OpenAI | `openai/` | `openai/gpt-4o` |
88
+ | DeepSeek | `deepseek/` | `deepseek/deepseek-chat` |
89
+ | Ollama | `ollama/` | `ollama/llama3` |
90
+
91
+ API keys are stored in `~/.axon/config.json`. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`) also work.
92
+
93
+ ## Configuration
94
+
95
+ All config is stored in `~/.axon/`:
96
+
97
+ | File | Purpose |
98
+ |------|---------|
99
+ | `~/.axon/config.json` | Server URL, backend, default model, API keys |
100
+ | `~/.axon/wallet.json` | Ethereum wallet (private key, address) |
101
+ | `~/.axon/sessions/<task_id>.json` | Mining session state (resume after disconnect) |
102
+ | `~/.axon/history/` | Mining history per task |
103
+ | `~/.axon/logs/` | Debug logs |
104
+
105
+ Config keys:
106
+
107
+ | Key | Default |
108
+ |-----|---------|
109
+ | `server_url` | `http://localhost:8000` |
110
+ | `default_model` | `anthropic/claude-sonnet-4-20250514` |
111
+ | `backend` | `auto` |
112
+ | `cli_timeout` | `600` (seconds, for CLI backends; set `0` or `null` to disable the hard timeout) |
113
+ | `claude_cli_model` | *(empty — uses CLI default)* |
114
+ | `codex_cli_model` | *(empty — uses CLI default)* |
115
+
116
+ ## Authentication Flow
117
+
118
+ The CLI uses wallet-based auth (Ethereum signatures, no passwords):
119
+
120
+ ```
121
+ 1. axon onboard → generates ETH keypair → ~/.axon/wallet.json
122
+ 2. GET /api/auth/nonce → server returns challenge nonce
123
+ 3. Sign nonce with private key
124
+ 4. POST /api/auth/verify → server returns JWT
125
+ 5. All API calls use Authorization: Bearer <JWT>
126
+ ```
127
+
128
+ Authentication is automatic. The CLI re-authenticates transparently when the token expires.
129
+
130
+ ## Project Structure
131
+
132
+ ```
133
+ cli/
134
+ ├── axon/
135
+ │ ├── cli.py Typer app, all commands
136
+ │ ├── config.py Config load/save (~/.axon/config.json)
137
+ │ ├── wallet.py ETH wallet generation + signing
138
+ │ ├── api.py HTTP client (httpx) with auto-auth
139
+ │ ├── llm.py LLM integration via litellm
140
+ │ ├── mining.py Mining loop + Rich Live display
141
+ │ ├── session.py Session persistence (resume mining)
142
+ │ ├── display.py Rich tables, panels, formatting
143
+ │ ├── providers.py Model list fetching per provider
144
+ │ ├── history.py Mining history tracking
145
+ │ ├── log.py Logging setup
146
+ │ ├── backends/ Mining backend system
147
+ │ │ ├── base.py Abstract backend interface
148
+ │ │ ├── litellm_backend.py LiteLLM API backend
149
+ │ │ ├── claude_cli.py Claude Code CLI backend
150
+ │ │ ├── codex_cli.py Codex CLI backend
151
+ │ │ └── registry.py Auto-detection + registration
152
+ ├── tests/
153
+ └── pyproject.toml
154
+ ```
155
+
156
+ ## Dependencies
157
+
158
+ - **typer** -- CLI framework
159
+ - **rich** -- Terminal formatting
160
+ - **litellm** -- Unified LLM API
161
+ - **httpx** -- HTTP client
162
+ - **eth-account** -- Wallet generation + signing
163
+ - **simple-term-menu** -- Arrow-key selection menus
164
+
165
+ ## License
166
+
167
+ MIT
File without changes
@@ -0,0 +1,83 @@
1
+ """Backend HTTP client with automatic wallet auth."""
2
+ import httpx
3
+ from axon.config import load_config, get_token, save_config
4
+
5
+
6
+ def _ensure_auth():
7
+ """Auto-authenticate with wallet if no valid token."""
8
+ transport = httpx.HTTPTransport(proxy=None)
9
+
10
+ token = get_token()
11
+ if token:
12
+ config = load_config()
13
+ try:
14
+ with httpx.Client(base_url=config["server_url"], timeout=5, transport=transport) as c:
15
+ resp = c.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
16
+ if resp.status_code == 200:
17
+ return # Token still valid
18
+ except httpx.ConnectError:
19
+ raise
20
+ except Exception:
21
+ pass # Token expired or invalid, try re-auth below
22
+
23
+ # Token missing or expired — re-auth with wallet
24
+ from axon.wallet import load_wallet, sign_message
25
+ wallet = load_wallet()
26
+ if not wallet:
27
+ return
28
+
29
+ config = load_config()
30
+ with httpx.Client(base_url=config["server_url"], timeout=10, transport=transport) as c:
31
+ # Get nonce
32
+ resp = c.get(f"/api/auth/nonce?address={wallet['address']}")
33
+ if resp.status_code != 200:
34
+ return
35
+ nonce_data = resp.json()
36
+
37
+ # Sign
38
+ signature = sign_message(nonce_data["message"], wallet["private_key"])
39
+
40
+ # Verify
41
+ resp = c.post("/api/auth/verify", json={
42
+ "address": wallet["address"],
43
+ "signature": signature,
44
+ })
45
+ if resp.status_code == 200:
46
+ save_config({"auth_token": resp.json()["access_token"]})
47
+
48
+
49
+ def _client(auth: bool = True, timeout: int = 120) -> httpx.Client:
50
+ if auth:
51
+ _ensure_auth()
52
+ config = load_config()
53
+ headers = {"Content-Type": "application/json"}
54
+ token = get_token()
55
+ if auth and token:
56
+ headers["Authorization"] = f"Bearer {token}"
57
+ return httpx.Client(
58
+ base_url=config["server_url"],
59
+ headers=headers,
60
+ timeout=timeout,
61
+ transport=httpx.HTTPTransport(proxy=None),
62
+ )
63
+
64
+
65
+ def api_get(path: str, auth: bool = True) -> dict | list:
66
+ with _client(auth=auth) as c:
67
+ resp = c.get(path)
68
+ resp.raise_for_status()
69
+ return resp.json()
70
+
71
+
72
+ def api_post(path: str, body: dict, auth: bool = True) -> dict:
73
+ with _client(auth=auth) as c:
74
+ resp = c.post(path, json=body)
75
+ resp.raise_for_status()
76
+ return resp.json()
77
+
78
+
79
+ def api_patch(path: str, body: dict | None = None, auth: bool = True) -> dict:
80
+ with _client(auth=auth) as c:
81
+ resp = c.patch(path, json=body) if body else c.patch(path)
82
+ resp.raise_for_status()
83
+ return resp.json()
@@ -0,0 +1,5 @@
1
+ """Axon mining backends — litellm, claude-cli, codex-cli."""
2
+ from axon.backends.base import Backend, BackendResult
3
+ from axon.backends.registry import auto_detect_backend, create_backend
4
+
5
+ __all__ = ["Backend", "BackendResult", "auto_detect_backend", "create_backend"]
@@ -0,0 +1,23 @@
1
+ """Backend protocol and result type for mining LLM calls."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol, TypedDict, runtime_checkable
5
+
6
+
7
+ class BackendResult(TypedDict):
8
+ thinking: str
9
+ answer: str
10
+ usage: dict # {billing_mode, tokens, cost_usd, total_tokens, prompt_tokens, completion_tokens, cost}
11
+
12
+
13
+ @runtime_checkable
14
+ class Backend(Protocol):
15
+ name: str
16
+
17
+ def call(self, prompt: str, task: dict) -> BackendResult:
18
+ """Call the backend with a prompt and task context. Returns structured result."""
19
+ ...
20
+
21
+ def display_name(self) -> str:
22
+ """Human-readable name for display in status lines."""
23
+ ...
@@ -0,0 +1,290 @@
1
+ """Claude Code CLI backend — runs `claude -p` as subprocess."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import shlex
9
+ import signal
10
+ import subprocess
11
+ import time
12
+ from datetime import datetime
13
+
14
+ from axon.backends.base import BackendResult
15
+ from axon.backends.registry import register
16
+ from axon.config import resolve_cli_timeout
17
+
18
+ log = logging.getLogger("axon.backend.claude")
19
+ _STREAM_SAMPLE_LIMIT = 20
20
+ _STREAM_SAMPLE_BYTES = 240
21
+ _SUBSCRIPTION_USAGE = {
22
+ "billing_mode": "subscription",
23
+ "tokens": None,
24
+ "cost_usd": None,
25
+ "total_tokens": None,
26
+ "prompt_tokens": None,
27
+ "completion_tokens": None,
28
+ "cost": None,
29
+ }
30
+
31
+
32
+ def _now_iso() -> str:
33
+ return datetime.now().astimezone().isoformat(timespec="seconds")
34
+
35
+
36
+ def _normalize_output(output: str | bytes | None) -> str:
37
+ if output is None:
38
+ return ""
39
+ if isinstance(output, bytes):
40
+ return output.decode("utf-8", errors="replace")
41
+ return output
42
+
43
+
44
+ def _log_output_sample(stream_name: str, output: str):
45
+ if not output:
46
+ return
47
+ lines = output.splitlines()
48
+ for line_count, line in enumerate(lines[:_STREAM_SAMPLE_LIMIT], start=1):
49
+ log.info("Claude CLI %s[%d]: %s", stream_name, line_count, line[:_STREAM_SAMPLE_BYTES])
50
+ if len(lines) > _STREAM_SAMPLE_LIMIT:
51
+ log.info("Claude CLI %s: further output truncated after %d lines", stream_name, _STREAM_SAMPLE_LIMIT)
52
+
53
+ # Tool sets by eval_type
54
+ _TOOLS_BY_EVAL_TYPE = {
55
+ "code_output": "Bash,Read,Write,Grep,Glob",
56
+ "llm_judge": "Read,WebSearch,WebFetch,Grep,Glob",
57
+ }
58
+ _DEFAULT_TOOLS = "Read,WebSearch,Grep,Glob"
59
+
60
+ # System prompts include output format instructions (no --json-schema, which
61
+ # conflicts with agentic multi-turn tool use and can cause infinite retries).
62
+ _SYSTEM_PROMPTS = {
63
+ "code_output": (
64
+ "You are solving a coding task. Write executable code that produces the correct output. "
65
+ "Use Bash to test your code before submitting your final answer. "
66
+ "Iterate until tests pass.\n\n"
67
+ "YOUR FINAL MESSAGE must contain ONLY raw executable Python code.\n"
68
+ "Do NOT wrap it in <answer> tags. Do NOT use markdown fences. Do NOT add explanation.\n"
69
+ "The evaluator writes your submission directly to solution.py and executes it."
70
+ ),
71
+ "llm_judge": (
72
+ "You are solving a research/reasoning task. Use WebSearch and WebFetch to find "
73
+ "accurate information. Verify facts before submitting. "
74
+ "Provide a thorough, well-reasoned answer in your final message."
75
+ ),
76
+ }
77
+ _DEFAULT_SYSTEM = (
78
+ "You are solving a task. Use available tools to research and verify your answer. "
79
+ "Be thorough and accurate. Put your final answer in your last message."
80
+ )
81
+
82
+
83
+ @register("claude-cli")
84
+ class ClaudeCLIBackend:
85
+ name = "claude-cli"
86
+
87
+ def __init__(self, config: dict):
88
+ self._timeout = resolve_cli_timeout(config)
89
+ self._model = config.get("claude_cli_model", "")
90
+
91
+ def call(self, prompt: str, task: dict) -> BackendResult:
92
+ eval_type = task.get("eval_type", "")
93
+ tools = _TOOLS_BY_EVAL_TYPE.get(eval_type, _DEFAULT_TOOLS)
94
+ system_prompt = _SYSTEM_PROMPTS.get(eval_type, _DEFAULT_SYSTEM)
95
+
96
+ # Prompt is passed via stdin (no positional arg) to avoid OS arg-length limits.
97
+ # No --json-schema: it conflicts with agentic tool-use and causes hangs.
98
+ cmd = [
99
+ "claude", "-p",
100
+ "--output-format", "json",
101
+ "--allowedTools", tools,
102
+ "--system-prompt", system_prompt,
103
+ "--dangerously-skip-permissions",
104
+ ]
105
+ if self._model:
106
+ cmd.extend(["--model", self._model])
107
+
108
+ started_at = _now_iso()
109
+ started_mono = time.monotonic()
110
+ timeout_label = "none" if self._timeout is None else f"{self._timeout}s"
111
+ log.info(
112
+ "Claude CLI start started_at=%s eval_type=%s tools=%s timeout=%s prompt_chars=%d cmd=%s",
113
+ started_at,
114
+ eval_type,
115
+ tools,
116
+ timeout_label,
117
+ len(prompt),
118
+ shlex.join(cmd),
119
+ )
120
+
121
+ # Clear env vars that make Claude CLI refuse to run inside another session
122
+ _blocked_env = {"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"}
123
+ env = {k: v for k, v in os.environ.items() if k not in _blocked_env}
124
+
125
+ # start_new_session creates a process group so we can kill all children
126
+ proc = subprocess.Popen(
127
+ cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
128
+ text=True, start_new_session=True, env=env,
129
+ )
130
+ try:
131
+ stdout, stderr = proc.communicate(input=prompt, timeout=self._timeout)
132
+ except subprocess.TimeoutExpired as exc:
133
+ stdout = _normalize_output(exc.stdout)
134
+ stderr = _normalize_output(exc.stderr)
135
+ _kill_process_group(proc)
136
+ _log_output_sample("stdout", stdout)
137
+ _log_output_sample("stderr", stderr)
138
+ log.error(
139
+ "Claude CLI timeout started_at=%s finished_at=%s duration_s=%.2f cmd=%s",
140
+ started_at,
141
+ _now_iso(),
142
+ time.monotonic() - started_mono,
143
+ shlex.join(cmd),
144
+ )
145
+ raise TimeoutError(f"Claude CLI timed out after {self._timeout}s") from None
146
+
147
+ _log_output_sample("stdout", stdout)
148
+ _log_output_sample("stderr", stderr)
149
+ if proc.returncode != 0:
150
+ log.error(
151
+ "Claude CLI failed started_at=%s finished_at=%s duration_s=%.2f returncode=%s cmd=%s stderr=%s",
152
+ started_at,
153
+ _now_iso(),
154
+ time.monotonic() - started_mono,
155
+ proc.returncode,
156
+ shlex.join(cmd),
157
+ stderr[:1000],
158
+ )
159
+ raise RuntimeError(f"Claude CLI exited with code {proc.returncode}: {stderr[:500]}")
160
+
161
+ if stderr:
162
+ log.debug("Claude CLI stderr: %s", stderr[:500])
163
+
164
+ log.info(
165
+ "Claude CLI finished started_at=%s finished_at=%s duration_s=%.2f returncode=%s stdout_chars=%d stderr_chars=%d",
166
+ started_at,
167
+ _now_iso(),
168
+ time.monotonic() - started_mono,
169
+ proc.returncode,
170
+ len(stdout),
171
+ len(stderr),
172
+ )
173
+
174
+ return _parse_response(stdout)
175
+
176
+ def display_name(self) -> str:
177
+ return f"claude-cli{f' ({self._model})' if self._model else ''}"
178
+
179
+
180
+ def _kill_process_group(proc: subprocess.Popen):
181
+ """Kill the process and its entire process group."""
182
+ try:
183
+ pgid = os.getpgid(proc.pid)
184
+ os.killpg(pgid, signal.SIGTERM)
185
+ except (ProcessLookupError, OSError):
186
+ try:
187
+ proc.kill()
188
+ except OSError:
189
+ pass
190
+ try:
191
+ proc.wait(timeout=5)
192
+ except subprocess.TimeoutExpired:
193
+ try:
194
+ pgid = os.getpgid(proc.pid)
195
+ os.killpg(pgid, signal.SIGKILL)
196
+ except (ProcessLookupError, OSError):
197
+ try:
198
+ proc.kill()
199
+ except OSError:
200
+ pass
201
+ try:
202
+ proc.wait(timeout=3)
203
+ except subprocess.TimeoutExpired:
204
+ log.warning("Process %d did not exit after SIGKILL", proc.pid)
205
+
206
+
207
+ def _parse_response(stdout: str) -> BackendResult:
208
+ """Parse Claude CLI JSON output.
209
+
210
+ `claude -p --output-format json` returns a single JSON object:
211
+ {type: "result", result: "...", total_cost_usd: ..., usage: {...}}
212
+ """
213
+ stdout = stdout.strip()
214
+ if not stdout:
215
+ raise RuntimeError("Claude CLI returned empty output")
216
+
217
+ data = json.loads(stdout)
218
+
219
+ # Extract the result text and usage from the response envelope
220
+ if isinstance(data, dict) and data.get("type") == "result":
221
+ content = data.get("result", "")
222
+ usage = _extract_usage(data)
223
+ elif isinstance(data, list):
224
+ # Fallback: older array format [{type:"system",...}, {type:"result",...}]
225
+ result_block = None
226
+ for block in data:
227
+ if isinstance(block, dict) and block.get("type") == "result":
228
+ result_block = block
229
+ break
230
+ if result_block is None:
231
+ raise RuntimeError("No result block found in Claude CLI output")
232
+ content = result_block.get("result", "")
233
+ usage = _extract_usage(result_block)
234
+ elif isinstance(data, dict):
235
+ # Direct dict with thinking/answer keys
236
+ return BackendResult(
237
+ thinking=data.get("thinking", ""),
238
+ answer=data.get("answer", str(data)),
239
+ usage=dict(_SUBSCRIPTION_USAGE),
240
+ )
241
+ else:
242
+ raise RuntimeError(f"Unexpected Claude CLI output type: {type(data)}")
243
+
244
+ return _extract_answer(content, usage)
245
+
246
+
247
+ def _extract_usage(envelope: dict) -> dict:
248
+ """Return subscription usage — Claude CLI is subscription-based, not metered."""
249
+ return dict(_SUBSCRIPTION_USAGE)
250
+
251
+
252
+ def _extract_answer(content, usage: dict) -> BackendResult:
253
+ """Parse the result field into thinking + answer."""
254
+ # If content is already a dict, extract fields
255
+ if isinstance(content, dict):
256
+ return BackendResult(
257
+ thinking=content.get("thinking", ""),
258
+ answer=content.get("answer", str(content)),
259
+ usage=usage,
260
+ )
261
+
262
+ text = str(content).strip()
263
+
264
+ # Try 1: Parse as JSON object with thinking/answer
265
+ try:
266
+ parsed = json.loads(text)
267
+ if isinstance(parsed, dict) and "answer" in parsed:
268
+ return BackendResult(
269
+ thinking=parsed.get("thinking", ""),
270
+ answer=parsed["answer"],
271
+ usage=usage,
272
+ )
273
+ except (json.JSONDecodeError, TypeError):
274
+ pass
275
+
276
+ # Try 2: Extract <thinking> and <answer> XML tags
277
+ think_match = re.search(r"<thinking>(.*?)</thinking>", text, re.DOTALL)
278
+ answer_match = re.search(r"<answer>(.*?)</answer>", text, re.DOTALL)
279
+ if answer_match:
280
+ return BackendResult(
281
+ thinking=think_match.group(1).strip() if think_match else "",
282
+ answer=answer_match.group(1).strip(),
283
+ usage=usage,
284
+ )
285
+
286
+ # Try 3: Strip markdown fences (common in code responses)
287
+ cleaned = re.sub(r"^```[\w]*\s*\n?", "", text)
288
+ cleaned = re.sub(r"\n?\s*```\s*$", "", cleaned)
289
+
290
+ return BackendResult(thinking="", answer=cleaned.strip(), usage=usage)