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.
Files changed (30) hide show
  1. spawnllm-0.6.0/PKG-INFO +129 -0
  2. spawnllm-0.6.0/README.md +82 -0
  3. {spawnllm-0.5.4 → spawnllm-0.6.0}/pyproject.toml +3 -8
  4. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/__init__.py +2 -1
  5. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/base.py +21 -0
  6. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/claude.py +34 -2
  7. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/codex.py +4 -1
  8. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/proc.py +71 -29
  9. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/response.py +25 -1
  10. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/run.py +33 -6
  11. spawnllm-0.5.4/PKG-INFO +0 -174
  12. spawnllm-0.5.4/README.md +0 -127
  13. {spawnllm-0.5.4 → spawnllm-0.6.0}/LICENSE +0 -0
  14. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/__main__.py +0 -0
  15. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/__init__.py +0 -0
  16. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/gemini.py +0 -0
  17. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/mlx.py +0 -0
  18. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/backends/registry.py +0 -0
  19. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/call.py +0 -0
  20. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/cli.py +0 -0
  21. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/extract.py +0 -0
  22. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/mlx/__init__.py +0 -0
  23. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/mlx/codec.py +0 -0
  24. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/mlx/engine.py +0 -0
  25. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/mlx/fuse.py +0 -0
  26. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/mlx/patches.py +0 -0
  27. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/py.typed +0 -0
  28. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/spec.py +0 -0
  29. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/structured.py +0 -0
  30. {spawnllm-0.5.4 → spawnllm-0.6.0}/spawnllm/types.py +0 -0
@@ -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
+ # ![spawnllm](https://github.com/yasyf/spawnllm/raw/main/docs/assets/readme-banner.webp)
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
+ [![CI](https://github.com/yasyf/spawnllm/actions/workflows/ci.yml/badge.svg)](https://github.com/yasyf/spawnllm/actions/workflows/ci.yml)
53
+ [![PyPI](https://img.shields.io/pypi/v/spawnllm)](https://pypi.org/project/spawnllm/)
54
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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).
@@ -0,0 +1,82 @@
1
+ # ![spawnllm](https://github.com/yasyf/spawnllm/raw/main/docs/assets/readme-banner.webp)
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
+ [![CI](https://github.com/yasyf/spawnllm/actions/workflows/ci.yml/badge.svg)](https://github.com/yasyf/spawnllm/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/spawnllm)](https://pypi.org/project/spawnllm/)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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.4"
5
- description = "Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools."
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
- # Tracking great-docs main until a release newer than 0.13.0: main carries
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. An optional
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
- result = subprocess.run(
99
- argv,
100
- input=input,
101
- capture_output=True,
102
- text=True,
103
- timeout=timeout,
104
- env=env,
105
- cwd=cwd,
106
- )
107
- return RunResult(result.stdout, result.stderr, result.returncode)
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 both stdout and stderr piped.
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
- return stdout_task.result(), bytes(stderr_buf), rc_task.result()
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
- proc = await asyncio.create_subprocess_exec(
214
- *argv,
215
- stdin=asyncio.subprocess.PIPE if input is not None else None,
216
- stdout=asyncio.subprocess.PIPE,
217
- stderr=asyncio.subprocess.PIPE,
218
- env=env,
219
- cwd=cwd,
220
- )
221
- if input is not None:
222
- assert proc.stdin is not None, "create_subprocess_exec was called with stdin=PIPE"
223
- proc.stdin.write(input.encode())
224
- await proc.stdin.drain()
225
- proc.stdin.close()
226
- collect = collect_process(proc)
227
- stdout, stderr, rc = await (asyncio.wait_for(collect, timeout) if timeout is not None else collect)
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
- ![spawnllm banner](https://github.com/yasyf/spawnllm/raw/main/docs/assets/readme-banner.webp)
51
-
52
- [![PyPI](https://img.shields.io/pypi/v/spawnllm.svg)](https://pypi.org/project/spawnllm/)
53
- [![Python](https://img.shields.io/pypi/pyversions/spawnllm.svg)](https://pypi.org/project/spawnllm/)
54
- [![Docs](https://img.shields.io/github/actions/workflow/status/yasyf/spawnllm/docs.yml?branch=main&label=docs)](https://yasyf.github.io/spawnllm/)
55
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
- ![spawnllm banner](https://github.com/yasyf/spawnllm/raw/main/docs/assets/readme-banner.webp)
4
-
5
- [![PyPI](https://img.shields.io/pypi/v/spawnllm.svg)](https://pypi.org/project/spawnllm/)
6
- [![Python](https://img.shields.io/pypi/pyversions/spawnllm.svg)](https://pypi.org/project/spawnllm/)
7
- [![Docs](https://img.shields.io/github/actions/workflow/status/yasyf/spawnllm/docs.yml?branch=main&label=docs)](https://yasyf.github.io/spawnllm/)
8
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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