spawnllm 0.5.4__tar.gz → 0.6.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.6.0/PKG-INFO +129 -0
- spawnllm-0.6.0/README.md +82 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/pyproject.toml +3 -8
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/__init__.py +2 -1
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/base.py +21 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/claude.py +34 -2
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/codex.py +4 -1
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/proc.py +71 -29
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/response.py +25 -1
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/run.py +33 -6
- spawnllm-0.5.4/PKG-INFO +0 -174
- spawnllm-0.5.4/README.md +0 -127
- {spawnllm-0.5.4 → spawnllm-0.6.0}/LICENSE +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/__main__.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/__init__.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/gemini.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/mlx.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/registry.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/call.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/cli.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/extract.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/mlx/engine.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/py.typed +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/spec.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/structured.py +0 -0
- {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/types.py +0 -0
spawnllm-0.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spawnllm
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Delete your subprocess wrappers around claude, codex, and gemini.
|
|
5
|
+
Keywords:
|
|
6
|
+
Author: Yasyf Mohamedali
|
|
7
|
+
Author-email: Yasyf Mohamedali <yasyfm@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Dist: click>=8
|
|
17
|
+
Requires-Dist: loguru>=0.7
|
|
18
|
+
Requires-Dist: pydantic>=2
|
|
19
|
+
Requires-Dist: openai>=2.43
|
|
20
|
+
Requires-Dist: anthropic>=0.111
|
|
21
|
+
Requires-Dist: zstandard>=0.25.0 ; extra == 'adapter'
|
|
22
|
+
Requires-Dist: numpy>=1.26 ; extra == 'adapter'
|
|
23
|
+
Requires-Dist: orjson>=3.10 ; extra == 'adapter'
|
|
24
|
+
Requires-Dist: anyio>=4 ; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.8 ; extra == 'dev'
|
|
27
|
+
Requires-Dist: ty>=0.0.44 ; extra == 'dev'
|
|
28
|
+
Requires-Dist: zstandard>=0.25.0 ; extra == 'dev'
|
|
29
|
+
Requires-Dist: numpy>=1.26 ; extra == 'dev'
|
|
30
|
+
Requires-Dist: orjson>=3.10 ; extra == 'dev'
|
|
31
|
+
Requires-Dist: zstandard>=0.25.0 ; extra == 'mlx'
|
|
32
|
+
Requires-Dist: numpy>=1.26 ; extra == 'mlx'
|
|
33
|
+
Requires-Dist: orjson>=3.10 ; extra == 'mlx'
|
|
34
|
+
Requires-Dist: anyio>=4.4 ; extra == 'mlx'
|
|
35
|
+
Requires-Dist: huggingface-hub>=0.25 ; extra == 'mlx'
|
|
36
|
+
Requires-Dist: mlx-lm>=0.31.3 ; platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'mlx'
|
|
37
|
+
Requires-Python: >=3.13
|
|
38
|
+
Project-URL: Homepage, https://github.com/yasyf/spawnllm
|
|
39
|
+
Project-URL: Documentation, https://yasyf.github.io/spawnllm/
|
|
40
|
+
Project-URL: Repository, https://github.com/yasyf/spawnllm
|
|
41
|
+
Project-URL: Issues, https://github.com/yasyf/spawnllm/issues
|
|
42
|
+
Project-URL: Changelog, https://github.com/yasyf/spawnllm/blob/main/CHANGELOG.md
|
|
43
|
+
Provides-Extra: adapter
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Provides-Extra: mlx
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# 
|
|
49
|
+
|
|
50
|
+
**Delete your subprocess wrappers around claude, codex, and gemini.** spawnllm subshells all three CLIs plus local MLX and returns one Pydantic-validated Response, so the per-model plumbing you hand-rolled goes away.
|
|
51
|
+
|
|
52
|
+
[](https://github.com/yasyf/spawnllm/actions/workflows/ci.yml)
|
|
53
|
+
[](https://pypi.org/project/spawnllm/)
|
|
54
|
+
[](https://github.com/yasyf/spawnllm/blob/main/LICENSE)
|
|
55
|
+
|
|
56
|
+
## Get started
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uvx spawnllm status
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
<img src="https://github.com/yasyf/spawnllm/raw/main/docs/assets/demo.png" alt="Terminal running 'uvx spawnllm status' — every backend reports ready and auto-selection picks claude" width="700">
|
|
63
|
+
|
|
64
|
+
Driving with an agent? Paste this:
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
Run `uv add spawnllm` in this project.
|
|
68
|
+
Replace our hand-rolled claude/codex subprocess code with spawnllm's `call_sync`,
|
|
69
|
+
or `extract_sync` with a Pydantic response model for structured output.
|
|
70
|
+
Verify available backends with `uvx spawnllm status`.
|
|
71
|
+
Docs: https://yasyf.github.io/spawnllm/
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Use cases
|
|
77
|
+
|
|
78
|
+
### Delete your hand-rolled claude/codex subprocess plumbing
|
|
79
|
+
|
|
80
|
+
Every small tool grows its own `subprocess.run(["claude", "-p", ...])` — argv quirks, stdin piping, exit-code guesswork — and each copy drifts. One call replaces all of it:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from spawnllm import call_sync
|
|
84
|
+
|
|
85
|
+
print(call_sync("Reply with just the word: pong"))
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Prints `pong`. With no `backend=`, spawnllm auto-selects the first installed, authenticated CLI, pipes the prompt over stdin, and retries transient 529/overloaded/rate-limit failures with capped backoff.
|
|
89
|
+
|
|
90
|
+
### Get a validated Pydantic object back, not a string to parse
|
|
91
|
+
|
|
92
|
+
Scraping JSON out of a model's stdout means regexes, code fences, and silent schema drift. `extract_sync` validates instead:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pydantic import BaseModel
|
|
96
|
+
|
|
97
|
+
from spawnllm import extract_sync
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Capital(BaseModel):
|
|
101
|
+
country: str
|
|
102
|
+
capital: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
result = extract_sync("What is the capital of France?", Capital)
|
|
106
|
+
print(result.capital) # Paris
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The backend turns `Capital` into a JSON-schema constraint on the call itself, and a non-conforming reply raises `pydantic.ValidationError` instead of sneaking downstream.
|
|
110
|
+
|
|
111
|
+
### Run Apple-Silicon MLX models with fused adapters and prompt-cache reuse
|
|
112
|
+
|
|
113
|
+
Shipping a LoRA-tuned local model means hand-rolling adapter fusion, model caching, and worker-thread lifecycle. The MLX extra owns all three:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uv add "spawnllm[mlx]"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`AdapterFuser.ensure_fused` fuses your compressed adapter into the base model once and caches the result in the Hugging Face hub layout; `MlxEngine` loads it on a dedicated worker thread, precomputes a prompt cache for your shared prefix messages, and batches generation. Wrap the engine in an `MlxBackend` and the same `run_sync` call works.
|
|
120
|
+
|
|
121
|
+
## More in the docs
|
|
122
|
+
|
|
123
|
+
- **Spec-driven runs** — a literal model id, per-provider flag passthrough, and envelope-aware retry via `RunSpec` — [Running reference](https://yasyf.github.io/spawnllm/reference/#running)
|
|
124
|
+
- **Backend selection** — the priority chain, plus `specialty=` routing (`debugging` and `review` go to Codex, `general` to Claude) — [Backends reference](https://yasyf.github.io/spawnllm/reference/#backends)
|
|
125
|
+
- **Transport helpers** — `run_cli`, `collect_process`, and `map_concurrent`, the subprocess plumbing shared by every CLI backend — [Transport reference](https://yasyf.github.io/spawnllm/reference/#transport)
|
|
126
|
+
- **The CLI** — `spawnllm call`, `status`, and `backends` from any shell — [CLI reference](https://yasyf.github.io/spawnllm/reference/cli/)
|
|
127
|
+
- **MLX internals** — the adapter codec, fuser, and runtime patches behind the local engine — [MLX reference](https://yasyf.github.io/spawnllm/reference/#mlx)
|
|
128
|
+
|
|
129
|
+
Read the [docs](https://yasyf.github.io/spawnllm/) for the full guide and API reference. Licensed under [MIT](https://github.com/yasyf/spawnllm/blob/main/LICENSE).
|
spawnllm-0.6.0/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# 
|
|
2
|
+
|
|
3
|
+
**Delete your subprocess wrappers around claude, codex, and gemini.** spawnllm subshells all three CLIs plus local MLX and returns one Pydantic-validated Response, so the per-model plumbing you hand-rolled goes away.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/yasyf/spawnllm/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/spawnllm/)
|
|
7
|
+
[](https://github.com/yasyf/spawnllm/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
## Get started
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uvx spawnllm status
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
<img src="https://github.com/yasyf/spawnllm/raw/main/docs/assets/demo.png" alt="Terminal running 'uvx spawnllm status' — every backend reports ready and auto-selection picks claude" width="700">
|
|
16
|
+
|
|
17
|
+
Driving with an agent? Paste this:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
Run `uv add spawnllm` in this project.
|
|
21
|
+
Replace our hand-rolled claude/codex subprocess code with spawnllm's `call_sync`,
|
|
22
|
+
or `extract_sync` with a Pydantic response model for structured output.
|
|
23
|
+
Verify available backends with `uvx spawnllm status`.
|
|
24
|
+
Docs: https://yasyf.github.io/spawnllm/
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Use cases
|
|
30
|
+
|
|
31
|
+
### Delete your hand-rolled claude/codex subprocess plumbing
|
|
32
|
+
|
|
33
|
+
Every small tool grows its own `subprocess.run(["claude", "-p", ...])` — argv quirks, stdin piping, exit-code guesswork — and each copy drifts. One call replaces all of it:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from spawnllm import call_sync
|
|
37
|
+
|
|
38
|
+
print(call_sync("Reply with just the word: pong"))
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Prints `pong`. With no `backend=`, spawnllm auto-selects the first installed, authenticated CLI, pipes the prompt over stdin, and retries transient 529/overloaded/rate-limit failures with capped backoff.
|
|
42
|
+
|
|
43
|
+
### Get a validated Pydantic object back, not a string to parse
|
|
44
|
+
|
|
45
|
+
Scraping JSON out of a model's stdout means regexes, code fences, and silent schema drift. `extract_sync` validates instead:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from pydantic import BaseModel
|
|
49
|
+
|
|
50
|
+
from spawnllm import extract_sync
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Capital(BaseModel):
|
|
54
|
+
country: str
|
|
55
|
+
capital: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
result = extract_sync("What is the capital of France?", Capital)
|
|
59
|
+
print(result.capital) # Paris
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The backend turns `Capital` into a JSON-schema constraint on the call itself, and a non-conforming reply raises `pydantic.ValidationError` instead of sneaking downstream.
|
|
63
|
+
|
|
64
|
+
### Run Apple-Silicon MLX models with fused adapters and prompt-cache reuse
|
|
65
|
+
|
|
66
|
+
Shipping a LoRA-tuned local model means hand-rolling adapter fusion, model caching, and worker-thread lifecycle. The MLX extra owns all three:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uv add "spawnllm[mlx]"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`AdapterFuser.ensure_fused` fuses your compressed adapter into the base model once and caches the result in the Hugging Face hub layout; `MlxEngine` loads it on a dedicated worker thread, precomputes a prompt cache for your shared prefix messages, and batches generation. Wrap the engine in an `MlxBackend` and the same `run_sync` call works.
|
|
73
|
+
|
|
74
|
+
## More in the docs
|
|
75
|
+
|
|
76
|
+
- **Spec-driven runs** — a literal model id, per-provider flag passthrough, and envelope-aware retry via `RunSpec` — [Running reference](https://yasyf.github.io/spawnllm/reference/#running)
|
|
77
|
+
- **Backend selection** — the priority chain, plus `specialty=` routing (`debugging` and `review` go to Codex, `general` to Claude) — [Backends reference](https://yasyf.github.io/spawnllm/reference/#backends)
|
|
78
|
+
- **Transport helpers** — `run_cli`, `collect_process`, and `map_concurrent`, the subprocess plumbing shared by every CLI backend — [Transport reference](https://yasyf.github.io/spawnllm/reference/#transport)
|
|
79
|
+
- **The CLI** — `spawnllm call`, `status`, and `backends` from any shell — [CLI reference](https://yasyf.github.io/spawnllm/reference/cli/)
|
|
80
|
+
- **MLX internals** — the adapter codec, fuser, and runtime patches behind the local engine — [MLX reference](https://yasyf.github.io/spawnllm/reference/#mlx)
|
|
81
|
+
|
|
82
|
+
Read the [docs](https://yasyf.github.io/spawnllm/) for the full guide and API reference. Licensed under [MIT](https://github.com/yasyf/spawnllm/blob/main/LICENSE).
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "spawnllm"
|
|
3
3
|
# Inert sentinel: the real version is set from the release tag (uv version --frozen).
|
|
4
|
-
version = "0.
|
|
5
|
-
description = "
|
|
4
|
+
version = "0.6.0"
|
|
5
|
+
description = "Delete your subprocess wrappers around claude, codex, and gemini."
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "MIT"
|
|
8
8
|
license-files = ["LICENSE"]
|
|
@@ -126,10 +126,5 @@ docs = [
|
|
|
126
126
|
# griffe (2.x) distribution, overriding the legacy griffe<2 pin great-docs
|
|
127
127
|
# itself still declares.
|
|
128
128
|
"griffelib>=2.0",
|
|
129
|
-
|
|
130
|
-
# build-time GitHub widget stats (embedded via the CI GITHUB_TOKEN), which
|
|
131
|
-
# drop the navbar widget's client-side API calls — the source of GitHub 403
|
|
132
|
-
# errors on the published site.
|
|
133
|
-
# TODO(bootstrap): revert to a PyPI pin (`great-docs>=0.14`) once released.
|
|
134
|
-
"great-docs @ git+https://github.com/posit-dev/great-docs@main",
|
|
129
|
+
"great-docs>=0.14",
|
|
135
130
|
]
|
|
@@ -28,7 +28,7 @@ from spawnllm.backends import (
|
|
|
28
28
|
)
|
|
29
29
|
from spawnllm.call import call, call_sync
|
|
30
30
|
from spawnllm.extract import extract, extract_sync
|
|
31
|
-
from spawnllm.response import Error, Output, Response, Result
|
|
31
|
+
from spawnllm.response import DiscardedAttempt, Error, Output, Response, Result
|
|
32
32
|
from spawnllm.run import run, run_sync
|
|
33
33
|
from spawnllm.spec import ClaudeConfig, CodexConfig, GeminiConfig, RunSpec
|
|
34
34
|
from spawnllm.types import ProviderName, TModel, TSpecialty
|
|
@@ -46,6 +46,7 @@ __all__ = [
|
|
|
46
46
|
"CliBackend",
|
|
47
47
|
"CodexCliBackend",
|
|
48
48
|
"CodexConfig",
|
|
49
|
+
"DiscardedAttempt",
|
|
49
50
|
"Error",
|
|
50
51
|
"GeminiCliBackend",
|
|
51
52
|
"GeminiConfig",
|
|
@@ -80,12 +80,16 @@ class Invocation:
|
|
|
80
80
|
stdin: Prompt text delivered over stdin, or `None` when delivered inline.
|
|
81
81
|
result_path: File the backend writes its final message to; when set, the
|
|
82
82
|
result is read from this file instead of stdout.
|
|
83
|
+
stdout_path: File the capture machinery redirects the child's stdout to;
|
|
84
|
+
when set, stdout goes to this regular file instead of a pipe and is
|
|
85
|
+
read back as the capture, dodging a Node child's async-pipe truncation.
|
|
83
86
|
cleanup_paths: Temp files to remove once the invocation completes.
|
|
84
87
|
"""
|
|
85
88
|
|
|
86
89
|
argv: list[str]
|
|
87
90
|
stdin: str | None = None
|
|
88
91
|
result_path: str | None = None
|
|
92
|
+
stdout_path: str | None = None
|
|
89
93
|
cleanup_paths: tuple[str, ...] = ()
|
|
90
94
|
|
|
91
95
|
|
|
@@ -244,6 +248,21 @@ class LlmBackend(ABC):
|
|
|
244
248
|
"""Return the provider's error message from an error envelope, or `None` on success."""
|
|
245
249
|
return None
|
|
246
250
|
|
|
251
|
+
def accounting(self, raw: str) -> tuple[float | None, dict[str, object] | None]:
|
|
252
|
+
"""Return the `(cost_usd, usage)` an attempt's output reports, or `(None, None)` when it carries neither.
|
|
253
|
+
|
|
254
|
+
The retry loop calls this on each transient failure it discards, so a
|
|
255
|
+
caller reconciling spend can still see the cost. The default parses
|
|
256
|
+
nothing; a backend whose envelope records spend overrides it.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
raw: The raw output read wherever the provider wrote it.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
A `(cost_usd, usage)` pair, each `None` when the output does not carry it.
|
|
263
|
+
"""
|
|
264
|
+
return None, None
|
|
265
|
+
|
|
247
266
|
|
|
248
267
|
class CliBackend(LlmBackend):
|
|
249
268
|
"""Execution contract for the subprocess-backed LLM family.
|
|
@@ -300,6 +319,7 @@ class CliBackend(LlmBackend):
|
|
|
300
319
|
env=os.environ | self.env(spec) | (spec.env or {}),
|
|
301
320
|
cwd=spec.cwd,
|
|
302
321
|
timeout=spec.timeout,
|
|
322
|
+
stdout_path=inv.stdout_path,
|
|
303
323
|
)
|
|
304
324
|
except TimeoutError:
|
|
305
325
|
return self.timed_out(spec)
|
|
@@ -319,6 +339,7 @@ class CliBackend(LlmBackend):
|
|
|
319
339
|
env=os.environ | self.env(spec) | (spec.env or {}),
|
|
320
340
|
cwd=spec.cwd,
|
|
321
341
|
timeout=spec.timeout,
|
|
342
|
+
stdout_path=inv.stdout_path,
|
|
322
343
|
)
|
|
323
344
|
except subprocess.TimeoutExpired:
|
|
324
345
|
return self.timed_out(spec)
|
|
@@ -11,9 +11,9 @@ import subprocess
|
|
|
11
11
|
import sys
|
|
12
12
|
import tempfile
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from typing import TYPE_CHECKING, ClassVar
|
|
14
|
+
from typing import TYPE_CHECKING, ClassVar, cast
|
|
15
15
|
|
|
16
|
-
from spawnllm.backends.base import CliBackend
|
|
16
|
+
from spawnllm.backends.base import CliBackend, Invocation
|
|
17
17
|
from spawnllm.spec import ClaudeConfig
|
|
18
18
|
from spawnllm.structured import structured_value
|
|
19
19
|
|
|
@@ -142,6 +142,27 @@ class ClaudeCliBackend(CliBackend):
|
|
|
142
142
|
*(["--verbose"] if cfg.verbose else []),
|
|
143
143
|
]
|
|
144
144
|
|
|
145
|
+
def invocation(self, spec: RunSpec) -> Invocation:
|
|
146
|
+
"""Build the `claude -p` invocation, capturing stdout to a real file.
|
|
147
|
+
|
|
148
|
+
`claude` is a Node process that writes its single-blob result to stdout
|
|
149
|
+
asynchronously; when stdout is a pipe and the process exits before that
|
|
150
|
+
write drains, Node drops the tail (observed truncating at exactly 64 KiB).
|
|
151
|
+
Pointing stdout at a regular file makes Node's write synchronous, so the
|
|
152
|
+
full result survives; the file is read back as stdout and removed after
|
|
153
|
+
the run.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
spec: The configured run to translate into an invocation.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
An `Invocation` whose stdout is captured to a temp file.
|
|
160
|
+
"""
|
|
161
|
+
argv = self.build_command(spec)
|
|
162
|
+
fd, stdout_path = tempfile.mkstemp(suffix=".json")
|
|
163
|
+
os.close(fd)
|
|
164
|
+
return Invocation(argv, spec.prompt, stdout_path=stdout_path, cleanup_paths=(stdout_path,))
|
|
165
|
+
|
|
145
166
|
def schema_for(self, model: type[BaseModel]) -> str:
|
|
146
167
|
"""Serialize a Pydantic model into Anthropic's structured-output JSON schema.
|
|
147
168
|
|
|
@@ -175,6 +196,17 @@ class ClaudeCliBackend(CliBackend):
|
|
|
175
196
|
return event["result"] if isinstance(event.get("result"), str) else "claude reported an error"
|
|
176
197
|
return None
|
|
177
198
|
|
|
199
|
+
def accounting(self, raw: str) -> tuple[float | None, dict[str, object] | None]:
|
|
200
|
+
"""Return `total_cost_usd` and `usage` from the `claude` result envelope, or `(None, None)` when absent."""
|
|
201
|
+
if (event := result_event(raw)) is None:
|
|
202
|
+
return None, None
|
|
203
|
+
cost = event.get("total_cost_usd")
|
|
204
|
+
usage = event.get("usage")
|
|
205
|
+
return (
|
|
206
|
+
float(cost) if isinstance(cost, int | float) else None,
|
|
207
|
+
cast("dict[str, object]", usage) if isinstance(usage, dict) else None,
|
|
208
|
+
)
|
|
209
|
+
|
|
178
210
|
def env(self, spec: RunSpec) -> dict[str, str]:
|
|
179
211
|
"""Point an isolated run at a fresh, host-free `CLAUDE_CONFIG_DIR`; otherwise add nothing.
|
|
180
212
|
|
|
@@ -23,7 +23,9 @@ class CodexCliBackend(CliBackend):
|
|
|
23
23
|
|
|
24
24
|
`build_command` translates a `RunSpec` into a `codex exec` argv that runs an
|
|
25
25
|
ephemeral session in a read-only sandbox; `invocation` resolves the schema to
|
|
26
|
-
a temp file and captures the final message to an `-o` file.
|
|
26
|
+
a temp file and captures the final message to an `-o` file. The argv carries
|
|
27
|
+
`--skip-git-repo-check` so a run in an untrusted or non-git `cwd` is not refused —
|
|
28
|
+
the sandbox already confines it, so the trust gate adds nothing here. An optional
|
|
27
29
|
`CodexConfig` overrides the sandbox and re-enables Codex hooks or MCP servers.
|
|
28
30
|
|
|
29
31
|
Attributes:
|
|
@@ -70,6 +72,7 @@ class CodexCliBackend(CliBackend):
|
|
|
70
72
|
"--ephemeral",
|
|
71
73
|
"--sandbox",
|
|
72
74
|
cfg.sandbox or "read-only",
|
|
75
|
+
"--skip-git-repo-check",
|
|
73
76
|
"--model",
|
|
74
77
|
model,
|
|
75
78
|
*(["-c", f"model_reasoning_effort={effort}"] if effort else []),
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import contextlib
|
|
6
7
|
import subprocess
|
|
7
8
|
from collections.abc import Awaitable, Callable, Sequence
|
|
8
9
|
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
9
11
|
|
|
10
12
|
__all__ = ["RunResult", "acapture_cli", "arun_cli", "capture_cli", "collect_process", "map_concurrent", "run_cli"]
|
|
11
13
|
|
|
@@ -76,6 +78,7 @@ def capture_cli(
|
|
|
76
78
|
timeout: int = 180,
|
|
77
79
|
env: dict[str, str] | None = None,
|
|
78
80
|
cwd: str | None = None,
|
|
81
|
+
stdout_path: str | None = None,
|
|
79
82
|
) -> RunResult:
|
|
80
83
|
"""Run a CLI command to completion and capture its full outcome.
|
|
81
84
|
|
|
@@ -88,6 +91,10 @@ def capture_cli(
|
|
|
88
91
|
timeout: Seconds to wait before the process is killed.
|
|
89
92
|
env: Environment for the process; `None` inherits the current environment.
|
|
90
93
|
cwd: Working directory for the process.
|
|
94
|
+
stdout_path: When set, the child writes stdout to this file (a regular
|
|
95
|
+
fd) instead of a pipe; the file is read back into `RunResult.stdout`.
|
|
96
|
+
A file makes a Node child's stdout writes synchronous, so a large
|
|
97
|
+
single-blob write is not truncated when the process exits.
|
|
91
98
|
|
|
92
99
|
Returns:
|
|
93
100
|
The captured stdout, stderr, and exit code.
|
|
@@ -95,16 +102,19 @@ def capture_cli(
|
|
|
95
102
|
Raises:
|
|
96
103
|
subprocess.TimeoutExpired: When the process outlives `timeout`.
|
|
97
104
|
"""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
with open(stdout_path, "wb") if stdout_path is not None else contextlib.nullcontext() as stdout_file:
|
|
106
|
+
result = subprocess.run(
|
|
107
|
+
argv,
|
|
108
|
+
input=input,
|
|
109
|
+
stdout=stdout_file if stdout_file is not None else subprocess.PIPE,
|
|
110
|
+
stderr=subprocess.PIPE,
|
|
111
|
+
text=True,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
env=env,
|
|
114
|
+
cwd=cwd,
|
|
115
|
+
)
|
|
116
|
+
stdout = Path(stdout_path).read_text() if stdout_path is not None else result.stdout
|
|
117
|
+
return RunResult(stdout, result.stderr, result.returncode)
|
|
108
118
|
|
|
109
119
|
|
|
110
120
|
async def collect_process(
|
|
@@ -115,20 +125,22 @@ async def collect_process(
|
|
|
115
125
|
"""Drain a subprocess's stdout and stderr concurrently and wait for it to exit.
|
|
116
126
|
|
|
117
127
|
Args:
|
|
118
|
-
proc: A process created with
|
|
128
|
+
proc: A process created with stderr piped and stdout either piped or
|
|
129
|
+
redirected to a file. A file-backed stdout (a `None` pipe) is not
|
|
130
|
+
drained here and comes back empty for the caller to read from the file.
|
|
119
131
|
stderr_tee: Callback invoked with each stderr line as it arrives.
|
|
120
132
|
|
|
121
133
|
Returns:
|
|
122
134
|
A `(stdout, stderr, returncode)` tuple.
|
|
123
135
|
"""
|
|
124
136
|
assert proc.stderr is not None, "create_subprocess_exec was called with stderr=PIPE"
|
|
125
|
-
assert proc.stdout is not None, "create_subprocess_exec was called with stdout=PIPE"
|
|
126
137
|
stderr_buf = bytearray()
|
|
127
138
|
async with asyncio.TaskGroup() as tg:
|
|
139
|
+
stdout_task = tg.create_task(proc.stdout.read()) if proc.stdout is not None else None
|
|
128
140
|
tg.create_task(_tee_stderr(proc.stderr, stderr_buf, stderr_tee))
|
|
129
|
-
stdout_task = tg.create_task(proc.stdout.read())
|
|
130
141
|
rc_task = tg.create_task(proc.wait())
|
|
131
|
-
|
|
142
|
+
stdout = stdout_task.result() if stdout_task is not None else b""
|
|
143
|
+
return stdout, bytes(stderr_buf), rc_task.result()
|
|
132
144
|
|
|
133
145
|
|
|
134
146
|
async def _tee_stderr(
|
|
@@ -142,6 +154,23 @@ async def _tee_stderr(
|
|
|
142
154
|
stderr_tee(raw)
|
|
143
155
|
|
|
144
156
|
|
|
157
|
+
async def _reap(proc: asyncio.subprocess.Process, *, grace: float = 2.0) -> None:
|
|
158
|
+
"""Terminate a still-running child (SIGTERM, then SIGKILL after `grace`) and wait for it to exit.
|
|
159
|
+
|
|
160
|
+
A no-op once the child has exited on its own; on a timed-out or cancelled
|
|
161
|
+
capture it stops the orphan — which keeps running and spending — before the
|
|
162
|
+
caller unlinks its output file.
|
|
163
|
+
"""
|
|
164
|
+
if proc.returncode is not None:
|
|
165
|
+
return
|
|
166
|
+
proc.terminate()
|
|
167
|
+
try:
|
|
168
|
+
await asyncio.wait_for(proc.wait(), grace)
|
|
169
|
+
except TimeoutError:
|
|
170
|
+
proc.kill()
|
|
171
|
+
await proc.wait()
|
|
172
|
+
|
|
173
|
+
|
|
145
174
|
async def arun_cli(
|
|
146
175
|
argv: list[str],
|
|
147
176
|
*,
|
|
@@ -191,6 +220,7 @@ async def acapture_cli(
|
|
|
191
220
|
env: dict[str, str] | None = None,
|
|
192
221
|
cwd: str | None = None,
|
|
193
222
|
timeout: int | None = None,
|
|
223
|
+
stdout_path: str | None = None,
|
|
194
224
|
) -> RunResult:
|
|
195
225
|
"""Run a CLI command asynchronously and capture its full outcome.
|
|
196
226
|
|
|
@@ -203,6 +233,11 @@ async def acapture_cli(
|
|
|
203
233
|
env: Environment for the process; `None` inherits the current environment.
|
|
204
234
|
cwd: Working directory for the process.
|
|
205
235
|
timeout: Seconds to wait before the wait is abandoned; `None` waits forever.
|
|
236
|
+
stdout_path: When set, the child writes stdout to this file (a regular
|
|
237
|
+
fd) instead of a pipe; the file is read back into `RunResult.stdout`.
|
|
238
|
+
A file makes a Node child's stdout writes synchronous, so a large
|
|
239
|
+
single-blob write is not truncated when the process exits before the
|
|
240
|
+
async pipe write drains.
|
|
206
241
|
|
|
207
242
|
Returns:
|
|
208
243
|
The captured stdout, stderr, and exit code.
|
|
@@ -210,21 +245,28 @@ async def acapture_cli(
|
|
|
210
245
|
Raises:
|
|
211
246
|
TimeoutError: When the process outlives `timeout`.
|
|
212
247
|
"""
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
248
|
+
with open(stdout_path, "wb") if stdout_path is not None else contextlib.nullcontext() as stdout_file:
|
|
249
|
+
proc = await asyncio.create_subprocess_exec(
|
|
250
|
+
*argv,
|
|
251
|
+
stdin=asyncio.subprocess.PIPE if input is not None else None,
|
|
252
|
+
stdout=stdout_file if stdout_file is not None else asyncio.subprocess.PIPE,
|
|
253
|
+
stderr=asyncio.subprocess.PIPE,
|
|
254
|
+
env=env,
|
|
255
|
+
cwd=cwd,
|
|
256
|
+
)
|
|
257
|
+
try:
|
|
258
|
+
if input is not None:
|
|
259
|
+
assert proc.stdin is not None, "create_subprocess_exec was called with stdin=PIPE"
|
|
260
|
+
proc.stdin.write(input.encode())
|
|
261
|
+
await proc.stdin.drain()
|
|
262
|
+
proc.stdin.close()
|
|
263
|
+
collect = collect_process(proc)
|
|
264
|
+
stdout, stderr, rc = await (asyncio.wait_for(collect, timeout) if timeout is not None else collect)
|
|
265
|
+
finally:
|
|
266
|
+
await _reap(proc)
|
|
267
|
+
if stdout_path is not None:
|
|
268
|
+
# Read after the direct child has exited; a descendant that writes here afterward is not captured.
|
|
269
|
+
stdout = Path(stdout_path).read_bytes()
|
|
228
270
|
return RunResult(stdout.decode(), stderr.decode(), rc)
|
|
229
271
|
|
|
230
272
|
|
|
@@ -57,6 +57,28 @@ class Output:
|
|
|
57
57
|
raw: str
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class DiscardedAttempt:
|
|
62
|
+
"""A transient failure the retry loop threw away, summarized for spend accounting.
|
|
63
|
+
|
|
64
|
+
`run`/`run_sync` return only the final attempt's `Response`, so a caller
|
|
65
|
+
tracking spend never sees the cost of the retries that preceded it. Each
|
|
66
|
+
discarded attempt is summarized here: `cost_usd` and `usage` when the
|
|
67
|
+
attempt's output carried parseable accounting, else `None`; `raw_bytes` is
|
|
68
|
+
always the UTF-8 byte length of the discarded output. `attempt` is the
|
|
69
|
+
zero-based index and `error` the discarded exception's class name.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
>>> DiscardedAttempt(attempt=0, error="BackendCallError", cost_usd=0.02, usage=None, raw_bytes=57)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
attempt: int
|
|
76
|
+
error: str
|
|
77
|
+
cost_usd: float | None
|
|
78
|
+
usage: dict[str, object] | None
|
|
79
|
+
raw_bytes: int
|
|
80
|
+
|
|
81
|
+
|
|
60
82
|
@dataclass(frozen=True, slots=True)
|
|
61
83
|
class Response:
|
|
62
84
|
"""A backend's fully-resolved outcome: the spec, the raw output, and exactly one of result/error.
|
|
@@ -66,7 +88,8 @@ class Response:
|
|
|
66
88
|
`output` are always present (the raw bytes live in `output.raw` even on
|
|
67
89
|
failure); exactly one of `result`/`error` is set. Every failure — a nonzero
|
|
68
90
|
exit, an error envelope, a timeout, or a validation error — routes through
|
|
69
|
-
`error`, never a raise from `run`.
|
|
91
|
+
`error`, never a raise from `run`. `discarded_attempts` carries the transient
|
|
92
|
+
failures `run` retried away before this one, empty when there were none.
|
|
70
93
|
|
|
71
94
|
Example:
|
|
72
95
|
>>> Response(spec=spec, output=Output(raw="hi"), result=Result(raw="hi"))
|
|
@@ -76,3 +99,4 @@ class Response:
|
|
|
76
99
|
output: Output
|
|
77
100
|
result: Result | None = None
|
|
78
101
|
error: Error | None = None
|
|
102
|
+
discarded_attempts: tuple[DiscardedAttempt, ...] = ()
|
|
@@ -4,9 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import time
|
|
7
|
+
from dataclasses import replace
|
|
7
8
|
from typing import TYPE_CHECKING
|
|
8
9
|
|
|
9
10
|
from spawnllm.backends.registry import select_backend
|
|
11
|
+
from spawnllm.response import DiscardedAttempt
|
|
10
12
|
from spawnllm.structured import backoff, is_transient
|
|
11
13
|
|
|
12
14
|
if TYPE_CHECKING:
|
|
@@ -17,6 +19,25 @@ if TYPE_CHECKING:
|
|
|
17
19
|
__all__ = ["run", "run_sync"]
|
|
18
20
|
|
|
19
21
|
|
|
22
|
+
def _discarded(backend: LlmBackend, resp: Response, attempt: int) -> DiscardedAttempt:
|
|
23
|
+
"""Summarize a transient `resp` the retry loop is about to discard on attempt `attempt`.
|
|
24
|
+
|
|
25
|
+
`accounting` is best-effort by contract, so a parse failure on a malformed
|
|
26
|
+
envelope degrades to no cost/usage rather than aborting the retry loop.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
cost_usd, usage = backend.accounting(resp.output.raw)
|
|
30
|
+
except Exception:
|
|
31
|
+
cost_usd, usage = None, None
|
|
32
|
+
return DiscardedAttempt(
|
|
33
|
+
attempt=attempt,
|
|
34
|
+
error=type(resp.error.ex).__name__,
|
|
35
|
+
cost_usd=cost_usd,
|
|
36
|
+
usage=usage,
|
|
37
|
+
raw_bytes=len(resp.output.raw.encode()),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
20
41
|
async def run(spec: RunSpec, *, backend: LlmBackend | None = None) -> Response:
|
|
21
42
|
"""Execute a `RunSpec` asynchronously, retrying transient failures with backoff.
|
|
22
43
|
|
|
@@ -32,15 +53,18 @@ async def run(spec: RunSpec, *, backend: LlmBackend | None = None) -> Response:
|
|
|
32
53
|
backend: The backend to run on; defaults to `select_backend()`.
|
|
33
54
|
|
|
34
55
|
Returns:
|
|
35
|
-
The resolved `Response` of the last attempt
|
|
56
|
+
The resolved `Response` of the last attempt, carrying every retried-away
|
|
57
|
+
attempt in `discarded_attempts`.
|
|
36
58
|
"""
|
|
37
59
|
backend = backend or select_backend()
|
|
60
|
+
discarded: list[DiscardedAttempt] = []
|
|
38
61
|
for attempt in range(spec.max_attempts):
|
|
39
62
|
resp = await backend.aexecute(spec)
|
|
40
|
-
if not is_transient(resp):
|
|
63
|
+
if not is_transient(resp) or attempt + 1 == spec.max_attempts:
|
|
41
64
|
break
|
|
65
|
+
discarded.append(_discarded(backend, resp, attempt))
|
|
42
66
|
await asyncio.sleep(backoff(attempt))
|
|
43
|
-
return resp
|
|
67
|
+
return replace(resp, discarded_attempts=tuple(discarded)) if discarded else resp
|
|
44
68
|
|
|
45
69
|
|
|
46
70
|
def run_sync(spec: RunSpec, *, backend: LlmBackend | None = None) -> Response:
|
|
@@ -55,12 +79,15 @@ def run_sync(spec: RunSpec, *, backend: LlmBackend | None = None) -> Response:
|
|
|
55
79
|
backend: The backend to run on; defaults to `select_backend()`.
|
|
56
80
|
|
|
57
81
|
Returns:
|
|
58
|
-
The resolved `Response` of the last attempt
|
|
82
|
+
The resolved `Response` of the last attempt, carrying every retried-away
|
|
83
|
+
attempt in `discarded_attempts`.
|
|
59
84
|
"""
|
|
60
85
|
backend = backend or select_backend()
|
|
86
|
+
discarded: list[DiscardedAttempt] = []
|
|
61
87
|
for attempt in range(spec.max_attempts):
|
|
62
88
|
resp = backend.execute(spec)
|
|
63
|
-
if not is_transient(resp):
|
|
89
|
+
if not is_transient(resp) or attempt + 1 == spec.max_attempts:
|
|
64
90
|
break
|
|
91
|
+
discarded.append(_discarded(backend, resp, attempt))
|
|
65
92
|
time.sleep(backoff(attempt))
|
|
66
|
-
return resp
|
|
93
|
+
return replace(resp, discarded_attempts=tuple(discarded)) if discarded else resp
|
spawnllm-0.5.4/PKG-INFO
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: spawnllm
|
|
3
|
-
Version: 0.5.4
|
|
4
|
-
Summary: Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools.
|
|
5
|
-
Keywords:
|
|
6
|
-
Author: Yasyf Mohamedali
|
|
7
|
-
Author-email: Yasyf Mohamedali <yasyfm@gmail.com>
|
|
8
|
-
License-Expression: MIT
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Classifier: Development Status :: 3 - Alpha
|
|
11
|
-
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: Operating System :: OS Independent
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
-
Classifier: Typing :: Typed
|
|
16
|
-
Requires-Dist: click>=8
|
|
17
|
-
Requires-Dist: loguru>=0.7
|
|
18
|
-
Requires-Dist: pydantic>=2
|
|
19
|
-
Requires-Dist: openai>=2.43
|
|
20
|
-
Requires-Dist: anthropic>=0.111
|
|
21
|
-
Requires-Dist: zstandard>=0.25.0 ; extra == 'adapter'
|
|
22
|
-
Requires-Dist: numpy>=1.26 ; extra == 'adapter'
|
|
23
|
-
Requires-Dist: orjson>=3.10 ; extra == 'adapter'
|
|
24
|
-
Requires-Dist: anyio>=4 ; extra == 'dev'
|
|
25
|
-
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
26
|
-
Requires-Dist: ruff>=0.8 ; extra == 'dev'
|
|
27
|
-
Requires-Dist: ty>=0.0.44 ; extra == 'dev'
|
|
28
|
-
Requires-Dist: zstandard>=0.25.0 ; extra == 'dev'
|
|
29
|
-
Requires-Dist: numpy>=1.26 ; extra == 'dev'
|
|
30
|
-
Requires-Dist: orjson>=3.10 ; extra == 'dev'
|
|
31
|
-
Requires-Dist: zstandard>=0.25.0 ; extra == 'mlx'
|
|
32
|
-
Requires-Dist: numpy>=1.26 ; extra == 'mlx'
|
|
33
|
-
Requires-Dist: orjson>=3.10 ; extra == 'mlx'
|
|
34
|
-
Requires-Dist: anyio>=4.4 ; extra == 'mlx'
|
|
35
|
-
Requires-Dist: huggingface-hub>=0.25 ; extra == 'mlx'
|
|
36
|
-
Requires-Dist: mlx-lm>=0.31.3 ; platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'mlx'
|
|
37
|
-
Requires-Python: >=3.13
|
|
38
|
-
Project-URL: Homepage, https://github.com/yasyf/spawnllm
|
|
39
|
-
Project-URL: Documentation, https://yasyf.github.io/spawnllm/
|
|
40
|
-
Project-URL: Repository, https://github.com/yasyf/spawnllm
|
|
41
|
-
Project-URL: Issues, https://github.com/yasyf/spawnllm/issues
|
|
42
|
-
Project-URL: Changelog, https://github.com/yasyf/spawnllm/blob/main/CHANGELOG.md
|
|
43
|
-
Provides-Extra: adapter
|
|
44
|
-
Provides-Extra: dev
|
|
45
|
-
Provides-Extra: mlx
|
|
46
|
-
Description-Content-Type: text/markdown
|
|
47
|
-
|
|
48
|
-
# spawnllm
|
|
49
|
-
|
|
50
|
-

|
|
51
|
-
|
|
52
|
-
[](https://pypi.org/project/spawnllm/)
|
|
53
|
-
[](https://pypi.org/project/spawnllm/)
|
|
54
|
-
[](https://yasyf.github.io/spawnllm/)
|
|
55
|
-
[](https://github.com/yasyf/spawnllm/blob/main/LICENSE)
|
|
56
|
-
|
|
57
|
-
Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools.
|
|
58
|
-
|
|
59
|
-
spawnllm centralizes the LLM-calling plumbing that small tools keep re-inventing: driving the
|
|
60
|
-
`claude` and `codex` CLIs as subshells — with structured Pydantic output, model tiers, and
|
|
61
|
-
faithful error capture — and running local Apple-Silicon MLX models with adapter fusion,
|
|
62
|
-
prompt-cache reuse, and batched generation. Depend on it once and each tool keeps only its
|
|
63
|
-
domain logic instead of its own copy of the backends.
|
|
64
|
-
|
|
65
|
-
## Install
|
|
66
|
-
|
|
67
|
-
Run the CLI with [uvx](https://docs.astral.sh/uv/):
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
uvx spawnllm --help
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
For the local MLX engine (Apple Silicon only), pull the extra: `uv add "spawnllm[mlx]"`.
|
|
74
|
-
|
|
75
|
-
## Quickstart
|
|
76
|
-
|
|
77
|
-
See which backends are installed and authenticated, and which one auto-selection picks:
|
|
78
|
-
|
|
79
|
-
```bash
|
|
80
|
-
uvx spawnllm status
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
```
|
|
84
|
-
claude: ready
|
|
85
|
-
codex: ready
|
|
86
|
-
selected: claude
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
Make a request by passing a prompt as the argument, or piping it over stdin:
|
|
90
|
-
|
|
91
|
-
```bash
|
|
92
|
-
uvx spawnllm call --backend claude "What is 2+2? Reply with just the number."
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
```
|
|
96
|
-
4
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
`--model small|medium|large` swaps the tier, which each backend maps to a concrete model — the
|
|
100
|
-
`claude` backend resolves `small` to Haiku, `medium` to Sonnet, and `large` to Opus. Add
|
|
101
|
-
`--agent` to let the call use tools. Run `uvx spawnllm --help` for the full flag list.
|
|
102
|
-
|
|
103
|
-
### From Python
|
|
104
|
-
|
|
105
|
-
`call_sync` runs one request and returns the response. With no `backend`, it auto-selects
|
|
106
|
-
the first installed, authenticated CLI (its async companion `call` mirrors the same
|
|
107
|
-
signature):
|
|
108
|
-
|
|
109
|
-
```python
|
|
110
|
-
from spawnllm import call_sync
|
|
111
|
-
|
|
112
|
-
print(call_sync("Reply with just the word: pong"))
|
|
113
|
-
# pong
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
Pin a backend and tier explicitly, or pass a Pydantic model to get a validated object back
|
|
117
|
-
instead of text:
|
|
118
|
-
|
|
119
|
-
```python
|
|
120
|
-
from pydantic import BaseModel
|
|
121
|
-
|
|
122
|
-
from spawnllm import call_sync, ClaudeCliBackend
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
class Capital(BaseModel):
|
|
126
|
-
country: str
|
|
127
|
-
capital: str
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
result = call_sync(
|
|
131
|
-
"What is the capital of France?",
|
|
132
|
-
backend=ClaudeCliBackend(),
|
|
133
|
-
model="large",
|
|
134
|
-
response_model=Capital,
|
|
135
|
-
)
|
|
136
|
-
print(result.capital) # Paris
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
When you don't pin a backend, set `specialty=` to scope auto-selection by task. The
|
|
140
|
-
`debugging` and `review` specialties route to Codex, and `general` routes to Claude.
|
|
141
|
-
|
|
142
|
-
### Spec-driven runs
|
|
143
|
-
|
|
144
|
-
For full control, build a `RunSpec` and execute it with `run_sync` (or its async companion
|
|
145
|
-
`run`). A `RunSpec` takes a literal provider model id — no tier mapping — and per-provider
|
|
146
|
-
flag passthrough via `provider_configs`. The call returns a `RunResult` with raw stdout,
|
|
147
|
-
stderr, and exit code, retrying transient `529`/overloaded/rate-limit failures with backoff:
|
|
148
|
-
|
|
149
|
-
```python
|
|
150
|
-
from spawnllm import run_sync, RunSpec, ClaudeConfig, ClaudeCliBackend
|
|
151
|
-
|
|
152
|
-
result = run_sync(
|
|
153
|
-
RunSpec(
|
|
154
|
-
prompt="What is 2+2? Reply with just the number.",
|
|
155
|
-
model="opus",
|
|
156
|
-
provider_configs={"claude": ClaudeConfig(permission_mode="bypassPermissions")},
|
|
157
|
-
),
|
|
158
|
-
backend=ClaudeCliBackend(),
|
|
159
|
-
)
|
|
160
|
-
print(result.stdout) # 4
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
## How it works
|
|
164
|
-
|
|
165
|
-
Each backend holds plumbing that consumers would otherwise rebuild: the CLI backends own argv
|
|
166
|
-
construction, stdin/stdout piping, stderr teeing, and turning non-zero exits into useful errors,
|
|
167
|
-
and they turn a Pydantic model into a JSON-schema constraint plus a parsed, validated result. The
|
|
168
|
-
MLX engine wraps adapter fusion, prompt-cache reuse, worker-thread lifecycle, and batched
|
|
169
|
-
single-token generation. Tools that share the layer stay byte-for-byte consistent instead of
|
|
170
|
-
drifting across diverging copies.
|
|
171
|
-
|
|
172
|
-
## Docs
|
|
173
|
-
|
|
174
|
-
[Read the docs](https://yasyf.github.io/spawnllm/) for the full guide and API reference.
|
spawnllm-0.5.4/README.md
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
# spawnllm
|
|
2
|
-
|
|
3
|
-

|
|
4
|
-
|
|
5
|
-
[](https://pypi.org/project/spawnllm/)
|
|
6
|
-
[](https://pypi.org/project/spawnllm/)
|
|
7
|
-
[](https://yasyf.github.io/spawnllm/)
|
|
8
|
-
[](https://github.com/yasyf/spawnllm/blob/main/LICENSE)
|
|
9
|
-
|
|
10
|
-
Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools.
|
|
11
|
-
|
|
12
|
-
spawnllm centralizes the LLM-calling plumbing that small tools keep re-inventing: driving the
|
|
13
|
-
`claude` and `codex` CLIs as subshells — with structured Pydantic output, model tiers, and
|
|
14
|
-
faithful error capture — and running local Apple-Silicon MLX models with adapter fusion,
|
|
15
|
-
prompt-cache reuse, and batched generation. Depend on it once and each tool keeps only its
|
|
16
|
-
domain logic instead of its own copy of the backends.
|
|
17
|
-
|
|
18
|
-
## Install
|
|
19
|
-
|
|
20
|
-
Run the CLI with [uvx](https://docs.astral.sh/uv/):
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
uvx spawnllm --help
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
For the local MLX engine (Apple Silicon only), pull the extra: `uv add "spawnllm[mlx]"`.
|
|
27
|
-
|
|
28
|
-
## Quickstart
|
|
29
|
-
|
|
30
|
-
See which backends are installed and authenticated, and which one auto-selection picks:
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
uvx spawnllm status
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
```
|
|
37
|
-
claude: ready
|
|
38
|
-
codex: ready
|
|
39
|
-
selected: claude
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Make a request by passing a prompt as the argument, or piping it over stdin:
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
uvx spawnllm call --backend claude "What is 2+2? Reply with just the number."
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
```
|
|
49
|
-
4
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
`--model small|medium|large` swaps the tier, which each backend maps to a concrete model — the
|
|
53
|
-
`claude` backend resolves `small` to Haiku, `medium` to Sonnet, and `large` to Opus. Add
|
|
54
|
-
`--agent` to let the call use tools. Run `uvx spawnllm --help` for the full flag list.
|
|
55
|
-
|
|
56
|
-
### From Python
|
|
57
|
-
|
|
58
|
-
`call_sync` runs one request and returns the response. With no `backend`, it auto-selects
|
|
59
|
-
the first installed, authenticated CLI (its async companion `call` mirrors the same
|
|
60
|
-
signature):
|
|
61
|
-
|
|
62
|
-
```python
|
|
63
|
-
from spawnllm import call_sync
|
|
64
|
-
|
|
65
|
-
print(call_sync("Reply with just the word: pong"))
|
|
66
|
-
# pong
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
Pin a backend and tier explicitly, or pass a Pydantic model to get a validated object back
|
|
70
|
-
instead of text:
|
|
71
|
-
|
|
72
|
-
```python
|
|
73
|
-
from pydantic import BaseModel
|
|
74
|
-
|
|
75
|
-
from spawnllm import call_sync, ClaudeCliBackend
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
class Capital(BaseModel):
|
|
79
|
-
country: str
|
|
80
|
-
capital: str
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
result = call_sync(
|
|
84
|
-
"What is the capital of France?",
|
|
85
|
-
backend=ClaudeCliBackend(),
|
|
86
|
-
model="large",
|
|
87
|
-
response_model=Capital,
|
|
88
|
-
)
|
|
89
|
-
print(result.capital) # Paris
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
When you don't pin a backend, set `specialty=` to scope auto-selection by task. The
|
|
93
|
-
`debugging` and `review` specialties route to Codex, and `general` routes to Claude.
|
|
94
|
-
|
|
95
|
-
### Spec-driven runs
|
|
96
|
-
|
|
97
|
-
For full control, build a `RunSpec` and execute it with `run_sync` (or its async companion
|
|
98
|
-
`run`). A `RunSpec` takes a literal provider model id — no tier mapping — and per-provider
|
|
99
|
-
flag passthrough via `provider_configs`. The call returns a `RunResult` with raw stdout,
|
|
100
|
-
stderr, and exit code, retrying transient `529`/overloaded/rate-limit failures with backoff:
|
|
101
|
-
|
|
102
|
-
```python
|
|
103
|
-
from spawnllm import run_sync, RunSpec, ClaudeConfig, ClaudeCliBackend
|
|
104
|
-
|
|
105
|
-
result = run_sync(
|
|
106
|
-
RunSpec(
|
|
107
|
-
prompt="What is 2+2? Reply with just the number.",
|
|
108
|
-
model="opus",
|
|
109
|
-
provider_configs={"claude": ClaudeConfig(permission_mode="bypassPermissions")},
|
|
110
|
-
),
|
|
111
|
-
backend=ClaudeCliBackend(),
|
|
112
|
-
)
|
|
113
|
-
print(result.stdout) # 4
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
## How it works
|
|
117
|
-
|
|
118
|
-
Each backend holds plumbing that consumers would otherwise rebuild: the CLI backends own argv
|
|
119
|
-
construction, stdin/stdout piping, stderr teeing, and turning non-zero exits into useful errors,
|
|
120
|
-
and they turn a Pydantic model into a JSON-schema constraint plus a parsed, validated result. The
|
|
121
|
-
MLX engine wraps adapter fusion, prompt-cache reuse, worker-thread lifecycle, and batched
|
|
122
|
-
single-token generation. Tools that share the layer stay byte-for-byte consistent instead of
|
|
123
|
-
drifting across diverging copies.
|
|
124
|
-
|
|
125
|
-
## Docs
|
|
126
|
-
|
|
127
|
-
[Read the docs](https://yasyf.github.io/spawnllm/) for the full guide and API reference.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|