spawnllm 0.3.0__tar.gz → 0.4.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.
- {spawnllm-0.3.0 → spawnllm-0.4.0}/PKG-INFO +29 -7
- {spawnllm-0.3.0 → spawnllm-0.4.0}/README.md +28 -6
- {spawnllm-0.3.0 → spawnllm-0.4.0}/pyproject.toml +1 -1
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/__init__.py +18 -3
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/backends/__init__.py +4 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/backends/base.py +124 -42
- spawnllm-0.4.0/spawnllm/backends/claude.py +153 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/backends/codex.py +49 -28
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/backends/gemini.py +34 -33
- spawnllm-0.4.0/spawnllm/backends/mlx.py +66 -0
- spawnllm-0.4.0/spawnllm/call.py +97 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/cli.py +2 -2
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/mlx/engine.py +8 -4
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/proc.py +99 -1
- spawnllm-0.4.0/spawnllm/run.py +64 -0
- spawnllm-0.4.0/spawnllm/spec.py +94 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/structured.py +49 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/types.py +4 -1
- spawnllm-0.3.0/spawnllm/backends/claude.py +0 -194
- spawnllm-0.3.0/spawnllm/call.py +0 -54
- {spawnllm-0.3.0 → spawnllm-0.4.0}/LICENSE +0 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/__main__.py +0 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/backends/registry.py +0 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.3.0 → spawnllm-0.4.0}/spawnllm/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spawnllm
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools.
|
|
5
5
|
Keywords:
|
|
6
6
|
Author: Yasyf Mohamedali
|
|
@@ -113,13 +113,14 @@ The `claude` backend resolves `small` to Haiku, `medium` to Sonnet, and `large`
|
|
|
113
113
|
|
|
114
114
|
### From Python
|
|
115
115
|
|
|
116
|
-
`
|
|
117
|
-
first installed, authenticated CLI
|
|
116
|
+
`call_sync` runs one request and returns the response. With no `backend`, it auto-selects
|
|
117
|
+
the first installed, authenticated CLI (its async companion `call` mirrors the same
|
|
118
|
+
signature):
|
|
118
119
|
|
|
119
120
|
```python
|
|
120
|
-
from spawnllm import
|
|
121
|
+
from spawnllm import call_sync
|
|
121
122
|
|
|
122
|
-
print(
|
|
123
|
+
print(call_sync("Reply with just the word: pong"))
|
|
123
124
|
# pong
|
|
124
125
|
```
|
|
125
126
|
|
|
@@ -129,7 +130,7 @@ instead of text:
|
|
|
129
130
|
```python
|
|
130
131
|
from pydantic import BaseModel
|
|
131
132
|
|
|
132
|
-
from spawnllm import
|
|
133
|
+
from spawnllm import call_sync, ClaudeCliBackend
|
|
133
134
|
|
|
134
135
|
|
|
135
136
|
class Capital(BaseModel):
|
|
@@ -137,7 +138,7 @@ class Capital(BaseModel):
|
|
|
137
138
|
capital: str
|
|
138
139
|
|
|
139
140
|
|
|
140
|
-
result =
|
|
141
|
+
result = call_sync(
|
|
141
142
|
"What is the capital of France?",
|
|
142
143
|
backend=ClaudeCliBackend(),
|
|
143
144
|
model="large",
|
|
@@ -149,6 +150,27 @@ print(result.capital) # Paris
|
|
|
149
150
|
When you don't pin a backend, set `specialty=` to scope auto-selection by task. The
|
|
150
151
|
`debugging` and `review` specialties route to Codex, and `general` routes to Claude.
|
|
151
152
|
|
|
153
|
+
### Spec-driven runs
|
|
154
|
+
|
|
155
|
+
For full control, build a `RunSpec` and execute it with `run_sync` (or its async companion
|
|
156
|
+
`run`). A `RunSpec` takes a literal provider model id — no tier mapping — and per-provider
|
|
157
|
+
flag passthrough via `provider_configs`. The call returns a `RunResult` with raw stdout,
|
|
158
|
+
stderr, and exit code, retrying transient `529`/overloaded/rate-limit failures with backoff:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from spawnllm import run_sync, RunSpec, ClaudeConfig, ClaudeCliBackend
|
|
162
|
+
|
|
163
|
+
result = run_sync(
|
|
164
|
+
RunSpec(
|
|
165
|
+
prompt="What is 2+2? Reply with just the number.",
|
|
166
|
+
model="opus",
|
|
167
|
+
provider_configs={"claude": ClaudeConfig(permission_mode="bypassPermissions")},
|
|
168
|
+
),
|
|
169
|
+
backend=ClaudeCliBackend(),
|
|
170
|
+
)
|
|
171
|
+
print(result.stdout) # 4
|
|
172
|
+
```
|
|
173
|
+
|
|
152
174
|
## What problems does this solve?
|
|
153
175
|
|
|
154
176
|
Every tool that shells out to `claude` or `codex` rebuilds the same plumbing: argv
|
|
@@ -66,13 +66,14 @@ The `claude` backend resolves `small` to Haiku, `medium` to Sonnet, and `large`
|
|
|
66
66
|
|
|
67
67
|
### From Python
|
|
68
68
|
|
|
69
|
-
`
|
|
70
|
-
first installed, authenticated CLI
|
|
69
|
+
`call_sync` runs one request and returns the response. With no `backend`, it auto-selects
|
|
70
|
+
the first installed, authenticated CLI (its async companion `call` mirrors the same
|
|
71
|
+
signature):
|
|
71
72
|
|
|
72
73
|
```python
|
|
73
|
-
from spawnllm import
|
|
74
|
+
from spawnllm import call_sync
|
|
74
75
|
|
|
75
|
-
print(
|
|
76
|
+
print(call_sync("Reply with just the word: pong"))
|
|
76
77
|
# pong
|
|
77
78
|
```
|
|
78
79
|
|
|
@@ -82,7 +83,7 @@ instead of text:
|
|
|
82
83
|
```python
|
|
83
84
|
from pydantic import BaseModel
|
|
84
85
|
|
|
85
|
-
from spawnllm import
|
|
86
|
+
from spawnllm import call_sync, ClaudeCliBackend
|
|
86
87
|
|
|
87
88
|
|
|
88
89
|
class Capital(BaseModel):
|
|
@@ -90,7 +91,7 @@ class Capital(BaseModel):
|
|
|
90
91
|
capital: str
|
|
91
92
|
|
|
92
93
|
|
|
93
|
-
result =
|
|
94
|
+
result = call_sync(
|
|
94
95
|
"What is the capital of France?",
|
|
95
96
|
backend=ClaudeCliBackend(),
|
|
96
97
|
model="large",
|
|
@@ -102,6 +103,27 @@ print(result.capital) # Paris
|
|
|
102
103
|
When you don't pin a backend, set `specialty=` to scope auto-selection by task. The
|
|
103
104
|
`debugging` and `review` specialties route to Codex, and `general` routes to Claude.
|
|
104
105
|
|
|
106
|
+
### Spec-driven runs
|
|
107
|
+
|
|
108
|
+
For full control, build a `RunSpec` and execute it with `run_sync` (or its async companion
|
|
109
|
+
`run`). A `RunSpec` takes a literal provider model id — no tier mapping — and per-provider
|
|
110
|
+
flag passthrough via `provider_configs`. The call returns a `RunResult` with raw stdout,
|
|
111
|
+
stderr, and exit code, retrying transient `529`/overloaded/rate-limit failures with backoff:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from spawnllm import run_sync, RunSpec, ClaudeConfig, ClaudeCliBackend
|
|
115
|
+
|
|
116
|
+
result = run_sync(
|
|
117
|
+
RunSpec(
|
|
118
|
+
prompt="What is 2+2? Reply with just the number.",
|
|
119
|
+
model="opus",
|
|
120
|
+
provider_configs={"claude": ClaudeConfig(permission_mode="bypassPermissions")},
|
|
121
|
+
),
|
|
122
|
+
backend=ClaudeCliBackend(),
|
|
123
|
+
)
|
|
124
|
+
print(result.stdout) # 4
|
|
125
|
+
```
|
|
126
|
+
|
|
105
127
|
## What problems does this solve?
|
|
106
128
|
|
|
107
129
|
Every tool that shells out to `claude` or `codex` rebuilds the same plumbing: argv
|
|
@@ -15,22 +15,26 @@ from spawnllm.backends import (
|
|
|
15
15
|
BackendStatus,
|
|
16
16
|
BackendUnavailable,
|
|
17
17
|
ClaudeCliBackend,
|
|
18
|
+
CliBackend,
|
|
18
19
|
CodexCliBackend,
|
|
19
20
|
GeminiCliBackend,
|
|
20
21
|
Invocation,
|
|
21
22
|
LlmBackend,
|
|
22
23
|
LlmBackends,
|
|
24
|
+
MlxBackend,
|
|
23
25
|
select_backend,
|
|
24
26
|
)
|
|
25
|
-
from spawnllm.call import call
|
|
26
|
-
from spawnllm.proc import arun_cli, collect_process, map_concurrent, run_cli
|
|
27
|
+
from spawnllm.call import call, call_sync
|
|
28
|
+
from spawnllm.proc import RunResult, arun_cli, collect_process, map_concurrent, run_cli
|
|
29
|
+
from spawnllm.run import run, run_sync
|
|
30
|
+
from spawnllm.spec import ClaudeConfig, CodexConfig, GeminiConfig, RunSpec
|
|
27
31
|
from spawnllm.structured import (
|
|
28
32
|
extract_structured,
|
|
29
33
|
parse_result_envelope,
|
|
30
34
|
parse_structured_output,
|
|
31
35
|
resolve_schema_path,
|
|
32
36
|
)
|
|
33
|
-
from spawnllm.types import TModel, TSpecialty
|
|
37
|
+
from spawnllm.types import ProviderName, TModel, TSpecialty
|
|
34
38
|
|
|
35
39
|
__all__ = [
|
|
36
40
|
"AntigravityCliBackend",
|
|
@@ -40,21 +44,32 @@ __all__ = [
|
|
|
40
44
|
"BackendStatus",
|
|
41
45
|
"BackendUnavailable",
|
|
42
46
|
"ClaudeCliBackend",
|
|
47
|
+
"ClaudeConfig",
|
|
48
|
+
"CliBackend",
|
|
43
49
|
"CodexCliBackend",
|
|
50
|
+
"CodexConfig",
|
|
44
51
|
"GeminiCliBackend",
|
|
52
|
+
"GeminiConfig",
|
|
45
53
|
"Invocation",
|
|
46
54
|
"LlmBackend",
|
|
47
55
|
"LlmBackends",
|
|
56
|
+
"MlxBackend",
|
|
57
|
+
"ProviderName",
|
|
58
|
+
"RunResult",
|
|
59
|
+
"RunSpec",
|
|
48
60
|
"TModel",
|
|
49
61
|
"TSpecialty",
|
|
50
62
|
"arun_cli",
|
|
51
63
|
"call",
|
|
64
|
+
"call_sync",
|
|
52
65
|
"collect_process",
|
|
53
66
|
"extract_structured",
|
|
54
67
|
"map_concurrent",
|
|
55
68
|
"parse_result_envelope",
|
|
56
69
|
"parse_structured_output",
|
|
57
70
|
"resolve_schema_path",
|
|
71
|
+
"run",
|
|
58
72
|
"run_cli",
|
|
73
|
+
"run_sync",
|
|
59
74
|
"select_backend",
|
|
60
75
|
]
|
|
@@ -8,12 +8,14 @@ from spawnllm.backends.base import (
|
|
|
8
8
|
BackendReady,
|
|
9
9
|
BackendStatus,
|
|
10
10
|
BackendUnavailable,
|
|
11
|
+
CliBackend,
|
|
11
12
|
Invocation,
|
|
12
13
|
LlmBackend,
|
|
13
14
|
)
|
|
14
15
|
from spawnllm.backends.claude import ClaudeCliBackend
|
|
15
16
|
from spawnllm.backends.codex import CodexCliBackend
|
|
16
17
|
from spawnllm.backends.gemini import AntigravityCliBackend, GeminiCliBackend
|
|
18
|
+
from spawnllm.backends.mlx import MlxBackend
|
|
17
19
|
from spawnllm.backends.registry import LlmBackends, select_backend
|
|
18
20
|
|
|
19
21
|
__all__ = [
|
|
@@ -24,10 +26,12 @@ __all__ = [
|
|
|
24
26
|
"BackendStatus",
|
|
25
27
|
"BackendUnavailable",
|
|
26
28
|
"ClaudeCliBackend",
|
|
29
|
+
"CliBackend",
|
|
27
30
|
"CodexCliBackend",
|
|
28
31
|
"GeminiCliBackend",
|
|
29
32
|
"Invocation",
|
|
30
33
|
"LlmBackend",
|
|
31
34
|
"LlmBackends",
|
|
35
|
+
"MlxBackend",
|
|
32
36
|
"select_backend",
|
|
33
37
|
]
|
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
"""Abstract
|
|
1
|
+
"""Abstract execution contract for an LLM backend and its subprocess family."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import shutil
|
|
7
8
|
from abc import ABC, abstractmethod
|
|
8
9
|
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
9
11
|
from typing import TYPE_CHECKING, ClassVar
|
|
10
12
|
|
|
13
|
+
from spawnllm.proc import RunResult, acapture_cli, capture_cli
|
|
14
|
+
|
|
11
15
|
if TYPE_CHECKING:
|
|
12
16
|
from pydantic import BaseModel
|
|
13
17
|
|
|
14
|
-
from spawnllm.
|
|
18
|
+
from spawnllm.spec import RunSpec
|
|
19
|
+
from spawnllm.types import ProviderName, TModel
|
|
15
20
|
|
|
16
21
|
|
|
17
22
|
@dataclass(frozen=True)
|
|
@@ -76,38 +81,47 @@ class Invocation:
|
|
|
76
81
|
|
|
77
82
|
|
|
78
83
|
class LlmBackend(ABC):
|
|
79
|
-
"""Abstract
|
|
84
|
+
"""Abstract execution contract for an LLM backend.
|
|
80
85
|
|
|
81
86
|
Concrete backends map abstract model sizes to provider-specific model names
|
|
82
|
-
and encapsulate how to
|
|
87
|
+
and encapsulate how to execute a `RunSpec` and parse the raw response.
|
|
83
88
|
|
|
84
89
|
Attributes:
|
|
85
90
|
models: Mapping from abstract model size to the provider's model name.
|
|
91
|
+
provider: Provider identifier keying a `RunSpec`'s `provider_configs`.
|
|
86
92
|
"""
|
|
87
93
|
|
|
88
94
|
models: ClassVar[dict[TModel, str]]
|
|
89
|
-
|
|
90
|
-
install_hint: ClassVar[str]
|
|
95
|
+
provider: ClassVar[ProviderName]
|
|
91
96
|
|
|
92
97
|
@abstractmethod
|
|
93
|
-
def
|
|
94
|
-
"""
|
|
98
|
+
async def aexecute(self, spec: RunSpec) -> RunResult:
|
|
99
|
+
"""Execute a single run asynchronously and capture its raw outcome.
|
|
95
100
|
|
|
96
101
|
Args:
|
|
97
|
-
|
|
98
|
-
schema_path: Schema argument for structured output, or `None`.
|
|
99
|
-
agent: Whether the invocation may use tools / agent capabilities.
|
|
102
|
+
spec: The configured run to execute.
|
|
100
103
|
|
|
101
104
|
Returns:
|
|
102
|
-
The
|
|
105
|
+
The captured stdout, stderr, and exit code.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def execute(self, spec: RunSpec) -> RunResult:
|
|
110
|
+
"""Execute a single run synchronously and capture its raw outcome.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
spec: The configured run to execute.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The captured stdout, stderr, and exit code.
|
|
103
117
|
"""
|
|
104
118
|
|
|
105
119
|
@abstractmethod
|
|
106
120
|
def parse_response(self, raw: str, response_model: type[BaseModel] | None) -> str | BaseModel:
|
|
107
|
-
"""Parse raw
|
|
121
|
+
"""Parse raw stdout into text or a validated model.
|
|
108
122
|
|
|
109
123
|
Args:
|
|
110
|
-
raw: Raw stdout from the backend
|
|
124
|
+
raw: Raw stdout from the backend.
|
|
111
125
|
response_model: Model to validate against, or `None` for raw text.
|
|
112
126
|
|
|
113
127
|
Returns:
|
|
@@ -116,43 +130,36 @@ class LlmBackend(ABC):
|
|
|
116
130
|
|
|
117
131
|
@abstractmethod
|
|
118
132
|
def env(self) -> dict[str, str]:
|
|
119
|
-
"""Return extra environment variables for the
|
|
133
|
+
"""Return extra environment variables for the invocation, merged over the inherited environment."""
|
|
120
134
|
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
@abstractmethod
|
|
136
|
+
def is_authenticated(self, *, timeout: int) -> bool:
|
|
137
|
+
"""Probe whether the backend holds valid credentials for its provider.
|
|
138
|
+
|
|
139
|
+
"Authenticated" means the backend reports an active login session for the
|
|
140
|
+
provider, not merely that an executable is present on PATH.
|
|
123
141
|
|
|
124
142
|
Args:
|
|
125
|
-
timeout: Seconds to wait for the
|
|
143
|
+
timeout: Seconds to wait for the credential probe.
|
|
126
144
|
|
|
127
145
|
Returns:
|
|
128
|
-
`
|
|
129
|
-
is not on PATH, else `BackendNotAuthenticated`.
|
|
130
|
-
|
|
131
|
-
Raises:
|
|
132
|
-
subprocess.TimeoutExpired: If `is_authenticated` exceeds `timeout`.
|
|
146
|
+
`True` when the backend reports an authenticated session.
|
|
133
147
|
"""
|
|
134
|
-
if not shutil.which(self.binary):
|
|
135
|
-
return BackendNotInstalled(binary=self.binary, install_hint=self.install_hint)
|
|
136
|
-
if self.is_authenticated(timeout=timeout):
|
|
137
|
-
return BackendReady(binary=self.binary)
|
|
138
|
-
return BackendNotAuthenticated(binary=self.binary)
|
|
139
148
|
|
|
140
149
|
@abstractmethod
|
|
141
|
-
def
|
|
142
|
-
"""
|
|
143
|
-
|
|
144
|
-
"Authenticated" means the CLI reports an active login session for the
|
|
145
|
-
provider, not merely that the executable is present on PATH.
|
|
150
|
+
def check_status(self, *, timeout: int = 10) -> BackendStatus:
|
|
151
|
+
"""Check whether this backend is installed and authenticated.
|
|
146
152
|
|
|
147
153
|
Args:
|
|
148
|
-
timeout: Seconds to wait for the
|
|
154
|
+
timeout: Seconds to wait for the authentication probe.
|
|
149
155
|
|
|
150
156
|
Returns:
|
|
151
|
-
`
|
|
157
|
+
`BackendReady` when authenticated, `BackendNotInstalled` when the
|
|
158
|
+
backend is not available, else `BackendNotAuthenticated`.
|
|
152
159
|
"""
|
|
153
160
|
|
|
154
161
|
def schema_for(self, model: type[BaseModel]) -> str:
|
|
155
|
-
"""Serialize a Pydantic model into the JSON-schema string this backend
|
|
162
|
+
"""Serialize a Pydantic model into the JSON-schema string this backend expects.
|
|
156
163
|
|
|
157
164
|
The default emits the model's plain JSON schema; provider backends
|
|
158
165
|
override to apply their SDK's strict-schema transform.
|
|
@@ -165,7 +172,34 @@ class LlmBackend(ABC):
|
|
|
165
172
|
"""
|
|
166
173
|
return json.dumps(model.model_json_schema())
|
|
167
174
|
|
|
168
|
-
|
|
175
|
+
|
|
176
|
+
class CliBackend(LlmBackend):
|
|
177
|
+
"""Execution contract for the subprocess-backed LLM family.
|
|
178
|
+
|
|
179
|
+
Concrete CLI backends build an argv from a `RunSpec`; `aexecute`/`execute`
|
|
180
|
+
run it, merge environment overrides, and resolve the result from stdout or a
|
|
181
|
+
designated result file.
|
|
182
|
+
|
|
183
|
+
Attributes:
|
|
184
|
+
binary: Name of the backend's CLI executable on PATH.
|
|
185
|
+
install_hint: Suggested shell command to install the CLI.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
binary: ClassVar[str]
|
|
189
|
+
install_hint: ClassVar[str]
|
|
190
|
+
|
|
191
|
+
@abstractmethod
|
|
192
|
+
def build_command(self, spec: RunSpec) -> list[str]:
|
|
193
|
+
"""Build the CLI argv for a single invocation.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
spec: The configured run to translate into argv.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
The argv list to execute.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def invocation(self, spec: RunSpec) -> Invocation:
|
|
169
203
|
"""Build the argv, stdin, and result source for a single invocation.
|
|
170
204
|
|
|
171
205
|
The default delivers the prompt over stdin and reads the result from
|
|
@@ -173,12 +207,60 @@ class LlmBackend(ABC):
|
|
|
173
207
|
result from a file.
|
|
174
208
|
|
|
175
209
|
Args:
|
|
176
|
-
|
|
177
|
-
model: Provider-specific model name.
|
|
178
|
-
schema_path: Schema argument for structured output, or `None`.
|
|
179
|
-
agent: Whether the invocation may use tools / agent capabilities.
|
|
210
|
+
spec: The configured run to translate into an invocation.
|
|
180
211
|
|
|
181
212
|
Returns:
|
|
182
213
|
An `Invocation` carrying the argv, stdin text, and result source.
|
|
183
214
|
"""
|
|
184
|
-
return Invocation(self.build_command(
|
|
215
|
+
return Invocation(self.build_command(spec), spec.prompt)
|
|
216
|
+
|
|
217
|
+
async def aexecute(self, spec: RunSpec) -> RunResult:
|
|
218
|
+
inv = self.invocation(spec)
|
|
219
|
+
try:
|
|
220
|
+
rr = await acapture_cli(
|
|
221
|
+
inv.argv,
|
|
222
|
+
input=inv.stdin,
|
|
223
|
+
env=os.environ | self.env() | (spec.env or {}),
|
|
224
|
+
cwd=spec.cwd,
|
|
225
|
+
timeout=spec.timeout,
|
|
226
|
+
)
|
|
227
|
+
stdout = Path(inv.result_path).read_text() if inv.result_path else rr.stdout
|
|
228
|
+
finally:
|
|
229
|
+
for path in inv.cleanup_paths:
|
|
230
|
+
Path(path).unlink(missing_ok=True)
|
|
231
|
+
return RunResult(stdout, rr.stderr, rr.returncode)
|
|
232
|
+
|
|
233
|
+
def execute(self, spec: RunSpec) -> RunResult:
|
|
234
|
+
inv = self.invocation(spec)
|
|
235
|
+
try:
|
|
236
|
+
rr = capture_cli(
|
|
237
|
+
inv.argv,
|
|
238
|
+
input=inv.stdin,
|
|
239
|
+
env=os.environ | self.env() | (spec.env or {}),
|
|
240
|
+
cwd=spec.cwd,
|
|
241
|
+
timeout=spec.timeout,
|
|
242
|
+
)
|
|
243
|
+
stdout = Path(inv.result_path).read_text() if inv.result_path else rr.stdout
|
|
244
|
+
finally:
|
|
245
|
+
for path in inv.cleanup_paths:
|
|
246
|
+
Path(path).unlink(missing_ok=True)
|
|
247
|
+
return RunResult(stdout, rr.stderr, rr.returncode)
|
|
248
|
+
|
|
249
|
+
def check_status(self, *, timeout: int = 10) -> BackendStatus:
|
|
250
|
+
"""Check whether this backend's CLI is installed and authenticated.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
timeout: Seconds to wait for the authentication probe.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
`BackendReady` when authenticated, `BackendNotInstalled` when the CLI
|
|
257
|
+
is not on PATH, else `BackendNotAuthenticated`.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
subprocess.TimeoutExpired: If `is_authenticated` exceeds `timeout`.
|
|
261
|
+
"""
|
|
262
|
+
if not shutil.which(self.binary):
|
|
263
|
+
return BackendNotInstalled(binary=self.binary, install_hint=self.install_hint)
|
|
264
|
+
if self.is_authenticated(timeout=timeout):
|
|
265
|
+
return BackendReady(binary=self.binary)
|
|
266
|
+
return BackendNotAuthenticated(binary=self.binary)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""CliBackend for the Anthropic `claude` CLI, plus install/auth status checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
8
|
+
|
|
9
|
+
from spawnllm.backends.base import CliBackend
|
|
10
|
+
from spawnllm.spec import ClaudeConfig
|
|
11
|
+
from spawnllm.structured import parse_structured_output
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from spawnllm.spec import RunSpec
|
|
17
|
+
from spawnllm.types import ProviderName, TModel
|
|
18
|
+
|
|
19
|
+
CLAUDE_MODELS: dict[TModel, str] = {"small": "haiku", "medium": "sonnet", "large": "opus"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClaudeCliBackend(CliBackend):
|
|
23
|
+
"""`CliBackend` for the Anthropic `claude` CLI.
|
|
24
|
+
|
|
25
|
+
`build_command` translates a `RunSpec` into a `claude -p` argv with the prompt
|
|
26
|
+
delivered over stdin. The permission and system-prompt flags resolve through
|
|
27
|
+
three mutually exclusive branches: explicit `ClaudeConfig` agent fields, an
|
|
28
|
+
agent run, or a locked-down default. Orthogonal `ClaudeConfig` extras and the
|
|
29
|
+
output format are appended after.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
models: Mapping from abstract model size to a Claude model alias
|
|
33
|
+
(`haiku`/`sonnet`/`opus`).
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> from spawnllm.spec import RunSpec
|
|
37
|
+
>>> ClaudeCliBackend().build_command(RunSpec(prompt="hi", model="haiku"))[:5]
|
|
38
|
+
['claude', '-p', '--no-session-persistence', '--model', 'haiku']
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
models: ClassVar[dict[TModel, str]] = CLAUDE_MODELS
|
|
42
|
+
provider: ClassVar[ProviderName] = "claude"
|
|
43
|
+
binary: ClassVar[str] = "claude"
|
|
44
|
+
install_hint: ClassVar[str] = "curl -fsSL https://claude.ai/install.sh | bash"
|
|
45
|
+
|
|
46
|
+
def build_command(self, spec: RunSpec) -> list[str]:
|
|
47
|
+
"""Build the `claude -p` argv for one stdin-prompted invocation.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
spec: The configured run to translate into argv.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The argv list to execute; the prompt is delivered over stdin.
|
|
54
|
+
"""
|
|
55
|
+
cfg = spec.config_for(ClaudeConfig) or ClaudeConfig()
|
|
56
|
+
explicit = (
|
|
57
|
+
cfg.permission_mode is not None
|
|
58
|
+
or cfg.mcp_config is not None
|
|
59
|
+
or cfg.append_system_prompt is not None
|
|
60
|
+
or cfg.system_prompt is not None
|
|
61
|
+
or cfg.settings is not None
|
|
62
|
+
or bool(cfg.disallowed_tools)
|
|
63
|
+
or cfg.strict_mcp
|
|
64
|
+
)
|
|
65
|
+
return [
|
|
66
|
+
"claude",
|
|
67
|
+
"-p",
|
|
68
|
+
"--no-session-persistence",
|
|
69
|
+
"--model",
|
|
70
|
+
spec.model,
|
|
71
|
+
*(
|
|
72
|
+
[
|
|
73
|
+
*(["--permission-mode", cfg.permission_mode] if cfg.permission_mode is not None else []),
|
|
74
|
+
*(["--mcp-config", cfg.mcp_config] if cfg.mcp_config is not None else []),
|
|
75
|
+
*(["--strict-mcp-config"] if cfg.strict_mcp else []),
|
|
76
|
+
*(["--disallowedTools", *cfg.disallowed_tools] if cfg.disallowed_tools else []),
|
|
77
|
+
*(
|
|
78
|
+
["--append-system-prompt", cfg.append_system_prompt]
|
|
79
|
+
if cfg.append_system_prompt is not None
|
|
80
|
+
else []
|
|
81
|
+
),
|
|
82
|
+
*(["--settings", cfg.settings] if cfg.settings is not None else []),
|
|
83
|
+
*(["--max-budget-usd", str(cfg.max_budget_usd)] if cfg.max_budget_usd is not None else []),
|
|
84
|
+
]
|
|
85
|
+
if explicit
|
|
86
|
+
else ["--permission-mode", "auto", "--max-budget-usd", "1"]
|
|
87
|
+
if spec.agent
|
|
88
|
+
else ["--system-prompt", "", "--setting-sources", "", "--strict-mcp-config"]
|
|
89
|
+
),
|
|
90
|
+
*(["--system-prompt", cfg.system_prompt] if cfg.system_prompt is not None else []),
|
|
91
|
+
*(["--max-turns", str(cfg.max_turns)] if cfg.max_turns is not None else []),
|
|
92
|
+
*(["--tools", cfg.tools] if cfg.tools is not None else []),
|
|
93
|
+
*(["--disable-slash-commands"] if cfg.disable_slash_commands else []),
|
|
94
|
+
*(
|
|
95
|
+
["--json-schema", spec.schema, "--output-format", "json"]
|
|
96
|
+
if spec.schema
|
|
97
|
+
else ["--output-format", cfg.output_format]
|
|
98
|
+
if cfg.output_format
|
|
99
|
+
else []
|
|
100
|
+
),
|
|
101
|
+
*(["--verbose"] if cfg.verbose else []),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
def schema_for(self, model: type[BaseModel]) -> str:
|
|
105
|
+
"""Serialize a Pydantic model into Anthropic's structured-output JSON schema.
|
|
106
|
+
|
|
107
|
+
Uses the Anthropic SDK's `transform_schema`, which recursively sets
|
|
108
|
+
`additionalProperties: false` while preserving Pydantic's `required`,
|
|
109
|
+
producing the standard JSON Schema the `claude --json-schema` flag expects.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
model: The Pydantic model describing the structured output.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
A JSON-schema string passed inline to `--json-schema`.
|
|
116
|
+
"""
|
|
117
|
+
from anthropic.lib._parse._transform import transform_schema
|
|
118
|
+
|
|
119
|
+
return json.dumps(transform_schema(model))
|
|
120
|
+
|
|
121
|
+
def parse_response(self, raw: str, response_model: type[BaseModel] | None) -> str | BaseModel:
|
|
122
|
+
"""Parse `claude` stdout into text or a validated model.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
raw: Raw stdout from the `claude` CLI.
|
|
126
|
+
response_model: Model to validate against, or `None` for raw text.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
`raw` for text calls; otherwise the validated `structured_output` from the result event, else `raw` as JSON.
|
|
130
|
+
"""
|
|
131
|
+
return parse_structured_output(raw, response_model)
|
|
132
|
+
|
|
133
|
+
def env(self) -> dict[str, str]:
|
|
134
|
+
"""Return no extra environment variables; the `claude` CLI runs with the inherited environment."""
|
|
135
|
+
# CLAUDE_CODE_SIMPLE=1 breaks claude.ai keychain auth ("Not logged in")
|
|
136
|
+
# on current CLIs; --setting-sources ""/--strict-mcp-config already trim startup.
|
|
137
|
+
return {}
|
|
138
|
+
|
|
139
|
+
def is_authenticated(self, *, timeout: int) -> bool:
|
|
140
|
+
"""Report whether `claude auth status` exits cleanly, i.e. a claude.ai login is stored.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
timeout: Seconds to wait for `claude auth status`.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
`True` when the OAuth-aware probe reports a stored claude.ai login.
|
|
147
|
+
"""
|
|
148
|
+
return (
|
|
149
|
+
subprocess.run(
|
|
150
|
+
["claude", "auth", "status"], capture_output=True, text=True, timeout=timeout, check=False
|
|
151
|
+
).returncode
|
|
152
|
+
== 0
|
|
153
|
+
)
|