spawnllm 0.17.2__tar.gz → 0.17.4__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.17.2 → spawnllm-0.17.4}/PKG-INFO +1 -1
- {spawnllm-0.17.2 → spawnllm-0.17.4}/pyproject.toml +1 -1
- {spawnllm-0.17.2 → spawnllm-0.17.4}/pyproject.toml.orig +1 -1
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/backends/base.py +41 -2
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/backends/claude.py +16 -8
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/proc.py +61 -9
- {spawnllm-0.17.2 → spawnllm-0.17.4}/LICENSE +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/README.md +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/__init__.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/__main__.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/_core/__init__.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/_core/spawnllm_core.wasm +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/backends/__init__.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/backends/apple.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/backends/claude_sdk.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/backends/codex.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/backends/gemini.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/backends/mlx.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/backends/openai_endpoint.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/backends/registry.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/call.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/cli.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/decide.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/extract.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/mlx/engine.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/py.typed +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/response.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/run.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/spec.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/structured.py +0 -0
- {spawnllm-0.17.2 → spawnllm-0.17.4}/spawnllm/types.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
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.17.
|
|
4
|
+
version = "0.17.4"
|
|
5
5
|
description = "Delete your subprocess wrappers around claude, codex, and gemini."
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "MIT"
|
|
@@ -17,6 +17,10 @@ import subprocess
|
|
|
17
17
|
import sys
|
|
18
18
|
import tempfile
|
|
19
19
|
from abc import ABC, abstractmethod
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from contextvars import ContextVar
|
|
23
|
+
from copy import deepcopy
|
|
20
24
|
from dataclasses import dataclass
|
|
21
25
|
from pathlib import Path
|
|
22
26
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
@@ -33,6 +37,9 @@ if TYPE_CHECKING:
|
|
|
33
37
|
from spawnllm.types import ProviderName, TModel
|
|
34
38
|
|
|
35
39
|
|
|
40
|
+
INVOCATION_PLAN: ContextVar[InvocationPlanState | None] = ContextVar("spawnllm_invocation_plan", default=None)
|
|
41
|
+
|
|
42
|
+
|
|
36
43
|
@dataclass(frozen=True)
|
|
37
44
|
class BackendReady:
|
|
38
45
|
"""A backend whose CLI is installed and authenticated.
|
|
@@ -164,6 +171,14 @@ def run_probe(probe: dict[str, Any], *, binary: str, timeout: int) -> bool:
|
|
|
164
171
|
return False
|
|
165
172
|
|
|
166
173
|
|
|
174
|
+
@dataclass(slots=True)
|
|
175
|
+
class InvocationPlanState:
|
|
176
|
+
backend: LlmBackend
|
|
177
|
+
spec: RunSpec
|
|
178
|
+
value: dict[str, Any] | None = None
|
|
179
|
+
active: bool = True
|
|
180
|
+
|
|
181
|
+
|
|
167
182
|
class LlmBackend(ABC):
|
|
168
183
|
"""Abstract execution contract for an LLM backend.
|
|
169
184
|
|
|
@@ -312,6 +327,14 @@ class LlmBackend(ABC):
|
|
|
312
327
|
|
|
313
328
|
def core_plan(self, spec: RunSpec) -> dict[str, Any]:
|
|
314
329
|
"""Ask the core to plan this run's invocation (an exec argv or an HTTP request)."""
|
|
330
|
+
pending = INVOCATION_PLAN.get()
|
|
331
|
+
if pending is not None and pending.active and pending.backend is self and pending.spec is spec:
|
|
332
|
+
if pending.value is None:
|
|
333
|
+
pending.value = self._core_plan(spec)
|
|
334
|
+
return deepcopy(pending.value)
|
|
335
|
+
return self._core_plan(spec)
|
|
336
|
+
|
|
337
|
+
def _core_plan(self, spec: RunSpec) -> dict[str, Any]:
|
|
315
338
|
return _core.dispatch(
|
|
316
339
|
"plan", {"host": {"platform": sys.platform}, "provider": self.provider, "spec": self.wire_spec(spec)}
|
|
317
340
|
)
|
|
@@ -387,6 +410,16 @@ class CliBackend(LlmBackend):
|
|
|
387
410
|
binary: ClassVar[str]
|
|
388
411
|
install_hint: ClassVar[str]
|
|
389
412
|
|
|
413
|
+
@contextmanager
|
|
414
|
+
def _reuse_plan(self, spec: RunSpec) -> Iterator[None]:
|
|
415
|
+
pending = InvocationPlanState(self, spec)
|
|
416
|
+
token = INVOCATION_PLAN.set(pending)
|
|
417
|
+
try:
|
|
418
|
+
yield
|
|
419
|
+
finally:
|
|
420
|
+
pending.active = False
|
|
421
|
+
INVOCATION_PLAN.reset(token)
|
|
422
|
+
|
|
390
423
|
def binary_path(self) -> str:
|
|
391
424
|
"""Return the command every exec of this backend runs as `argv[0]`.
|
|
392
425
|
|
|
@@ -500,7 +533,10 @@ class CliBackend(LlmBackend):
|
|
|
500
533
|
return Response(spec=spec, output=Output(""), error=Error(msg, TimeoutError(msg)))
|
|
501
534
|
|
|
502
535
|
async def aexecute(self, spec: RunSpec) -> Response:
|
|
503
|
-
|
|
536
|
+
with self._reuse_plan(spec):
|
|
537
|
+
inv = self.invocation(spec)
|
|
538
|
+
env = self.env(spec)
|
|
539
|
+
return await self.aexecute_invocation(spec, inv, env)
|
|
504
540
|
|
|
505
541
|
async def aexecute_invocation(self, spec: RunSpec, inv: Invocation, env: dict[str, str]) -> Response:
|
|
506
542
|
"""Run a materialized `inv` asynchronously under `env` and resolve it, removing its temp files."""
|
|
@@ -525,7 +561,10 @@ class CliBackend(LlmBackend):
|
|
|
525
561
|
return self.to_response(raw, returncode=rr.returncode, stderr=rr.stderr, spec=spec)
|
|
526
562
|
|
|
527
563
|
def execute(self, spec: RunSpec) -> Response:
|
|
528
|
-
|
|
564
|
+
with self._reuse_plan(spec):
|
|
565
|
+
inv = self.invocation(spec)
|
|
566
|
+
env = self.env(spec)
|
|
567
|
+
return self.execute_invocation(spec, inv, env)
|
|
529
568
|
|
|
530
569
|
def execute_invocation(self, spec: RunSpec, inv: Invocation, env: dict[str, str]) -> Response:
|
|
531
570
|
"""Run a materialized `inv` under `env` and resolve it, removing its temp files."""
|
|
@@ -146,17 +146,25 @@ class ClaudeCliBackend(CliBackend):
|
|
|
146
146
|
return self.claude_isolation(spec.api_auth).env.get("CLAUDE_CODE_OAUTH_TOKEN") not in self._rejected_tokens
|
|
147
147
|
|
|
148
148
|
async def aexecute(self, spec: RunSpec) -> Response:
|
|
149
|
-
|
|
150
|
-
response = await self.aexecute_invocation(spec, self.invocation(spec), env)
|
|
151
|
-
while self.reject_credentials(spec, env, response):
|
|
149
|
+
with self._reuse_plan(spec):
|
|
152
150
|
env = self.env(spec)
|
|
153
|
-
|
|
151
|
+
inv = self.invocation(spec)
|
|
152
|
+
response = await self.aexecute_invocation(spec, inv, env)
|
|
153
|
+
while self.reject_credentials(spec, env, response):
|
|
154
|
+
with self._reuse_plan(spec):
|
|
155
|
+
env = self.env(spec)
|
|
156
|
+
inv = self.invocation(spec)
|
|
157
|
+
response = await self.aexecute_invocation(spec, inv, env)
|
|
154
158
|
return response
|
|
155
159
|
|
|
156
160
|
def execute(self, spec: RunSpec) -> Response:
|
|
157
|
-
|
|
158
|
-
response = self.execute_invocation(spec, self.invocation(spec), env)
|
|
159
|
-
while self.reject_credentials(spec, env, response):
|
|
161
|
+
with self._reuse_plan(spec):
|
|
160
162
|
env = self.env(spec)
|
|
161
|
-
|
|
163
|
+
inv = self.invocation(spec)
|
|
164
|
+
response = self.execute_invocation(spec, inv, env)
|
|
165
|
+
while self.reject_credentials(spec, env, response):
|
|
166
|
+
with self._reuse_plan(spec):
|
|
167
|
+
env = self.env(spec)
|
|
168
|
+
inv = self.invocation(spec)
|
|
169
|
+
response = self.execute_invocation(spec, inv, env)
|
|
162
170
|
return response
|
|
@@ -7,6 +7,7 @@ import contextlib
|
|
|
7
7
|
import os
|
|
8
8
|
import subprocess
|
|
9
9
|
import tempfile
|
|
10
|
+
import time
|
|
10
11
|
from collections.abc import Awaitable, Callable, Iterator, Sequence
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from pathlib import Path
|
|
@@ -16,6 +17,7 @@ __all__ = ["RunResult", "acapture_cli", "arun_cli", "capture_cli", "collect_proc
|
|
|
16
17
|
# Drain stderr in fixed chunks: `StreamReader.readline` (what `async for` uses) caps a
|
|
17
18
|
# line at 64 KiB and raises `LimitOverrunError` on a longer newline-free blob.
|
|
18
19
|
_STDERR_CHUNK = 1 << 16
|
|
20
|
+
CHECKPOINT_INTERVAL = 0.05
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
@dataclass(frozen=True, slots=True)
|
|
@@ -40,6 +42,7 @@ def run_cli(
|
|
|
40
42
|
timeout: int = 30,
|
|
41
43
|
env: dict[str, str] | None = None,
|
|
42
44
|
cwd: str | None = None,
|
|
45
|
+
checkpoint: Callable[[], None] | None = None,
|
|
43
46
|
) -> str:
|
|
44
47
|
"""Run a CLI command to completion and return its stdout.
|
|
45
48
|
|
|
@@ -49,6 +52,8 @@ def run_cli(
|
|
|
49
52
|
timeout: Seconds to wait before the process is killed.
|
|
50
53
|
env: Environment for the process; `None` inherits the current environment.
|
|
51
54
|
cwd: Working directory for the process.
|
|
55
|
+
checkpoint: POSIX-only check before spawning, while waiting, and before returning.
|
|
56
|
+
An exception stops and reaps the direct child, then propagates unchanged.
|
|
52
57
|
|
|
53
58
|
Returns:
|
|
54
59
|
The decoded stdout.
|
|
@@ -57,16 +62,20 @@ def run_cli(
|
|
|
57
62
|
subprocess.CalledProcessError: On a nonzero exit code, with the argv,
|
|
58
63
|
exit code, and stdout/stderr tails attached as notes.
|
|
59
64
|
subprocess.TimeoutExpired: When the process outlives `timeout`.
|
|
65
|
+
NotImplementedError: When a checkpoint is requested on a non-POSIX platform.
|
|
60
66
|
"""
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
if checkpoint is None:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
argv,
|
|
70
|
+
input=input,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
timeout=timeout,
|
|
74
|
+
env=env,
|
|
75
|
+
cwd=cwd,
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
result = cancellable_cli(argv, input=input, timeout=timeout, env=env, cwd=cwd, checkpoint=checkpoint)
|
|
70
79
|
if result.returncode != 0:
|
|
71
80
|
err = subprocess.CalledProcessError(result.returncode, argv, output=result.stdout, stderr=result.stderr)
|
|
72
81
|
err.add_note(f"argv: {argv}")
|
|
@@ -77,6 +86,49 @@ def run_cli(
|
|
|
77
86
|
return result.stdout
|
|
78
87
|
|
|
79
88
|
|
|
89
|
+
def cancellable_cli(
|
|
90
|
+
argv: list[str],
|
|
91
|
+
*,
|
|
92
|
+
input: str | None,
|
|
93
|
+
timeout: int,
|
|
94
|
+
env: dict[str, str] | None,
|
|
95
|
+
cwd: str | None,
|
|
96
|
+
checkpoint: Callable[[], None],
|
|
97
|
+
) -> subprocess.CompletedProcess[str]:
|
|
98
|
+
if os.name != "posix":
|
|
99
|
+
raise NotImplementedError("CLI cancellation checkpoints require POSIX")
|
|
100
|
+
checkpoint()
|
|
101
|
+
with subprocess.Popen(
|
|
102
|
+
argv,
|
|
103
|
+
stdin=subprocess.PIPE if input is not None else None,
|
|
104
|
+
stdout=subprocess.PIPE,
|
|
105
|
+
stderr=subprocess.PIPE,
|
|
106
|
+
text=True,
|
|
107
|
+
env=env,
|
|
108
|
+
cwd=cwd,
|
|
109
|
+
) as child:
|
|
110
|
+
deadline = time.monotonic() + timeout
|
|
111
|
+
partial = subprocess.TimeoutExpired(argv, timeout)
|
|
112
|
+
payload = input
|
|
113
|
+
try:
|
|
114
|
+
while True:
|
|
115
|
+
checkpoint()
|
|
116
|
+
if (remaining := deadline - time.monotonic()) <= 0:
|
|
117
|
+
raise subprocess.TimeoutExpired(argv, timeout, output=partial.output, stderr=partial.stderr)
|
|
118
|
+
try:
|
|
119
|
+
stdout, stderr = child.communicate(input=payload, timeout=min(CHECKPOINT_INTERVAL, remaining))
|
|
120
|
+
except subprocess.TimeoutExpired as expired:
|
|
121
|
+
partial = expired
|
|
122
|
+
else:
|
|
123
|
+
checkpoint()
|
|
124
|
+
return subprocess.CompletedProcess(argv, child.wait(), stdout, stderr)
|
|
125
|
+
payload = None
|
|
126
|
+
except BaseException:
|
|
127
|
+
child.kill()
|
|
128
|
+
child.wait()
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
|
|
80
132
|
def capture_cli(
|
|
81
133
|
argv: list[str],
|
|
82
134
|
*,
|
|
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
|
|
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
|