spawnllm 0.5.5__tar.gz → 0.6.1__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.5.5 → spawnllm-0.6.1}/PKG-INFO +2 -1
- {spawnllm-0.5.5 → spawnllm-0.6.1}/pyproject.toml +3 -7
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/__init__.py +4 -1
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/__init__.py +2 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/base.py +21 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/claude.py +34 -2
- spawnllm-0.6.1/spawnllm/backends/openai_endpoint.py +137 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/proc.py +71 -29
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/response.py +25 -1
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/run.py +33 -6
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/types.py +1 -1
- {spawnllm-0.5.5 → spawnllm-0.6.1}/LICENSE +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/README.md +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/__main__.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/codex.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/gemini.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/mlx.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/registry.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/call.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/cli.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/extract.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/mlx/engine.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/py.typed +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/spec.py +0 -0
- {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/structured.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spawnllm
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Delete your subprocess wrappers around claude, codex, and gemini.
|
|
5
5
|
Keywords:
|
|
6
6
|
Author: Yasyf Mohamedali
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
15
|
Classifier: Typing :: Typed
|
|
16
16
|
Requires-Dist: click>=8
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
17
18
|
Requires-Dist: loguru>=0.7
|
|
18
19
|
Requires-Dist: pydantic>=2
|
|
19
20
|
Requires-Dist: openai>=2.43
|
|
@@ -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.
|
|
4
|
+
version = "0.6.1"
|
|
5
5
|
description = "Delete your subprocess wrappers around claude, codex, and gemini."
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "MIT"
|
|
@@ -19,6 +19,7 @@ classifiers = [
|
|
|
19
19
|
requires-python = ">=3.13"
|
|
20
20
|
dependencies = [
|
|
21
21
|
"click>=8",
|
|
22
|
+
"httpx>=0.27",
|
|
22
23
|
"loguru>=0.7",
|
|
23
24
|
"pydantic>=2",
|
|
24
25
|
# Per-backend strict-schema transforms: openai.lib._pydantic.to_strict_json_schema
|
|
@@ -126,10 +127,5 @@ docs = [
|
|
|
126
127
|
# griffe (2.x) distribution, overriding the legacy griffe<2 pin great-docs
|
|
127
128
|
# itself still declares.
|
|
128
129
|
"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",
|
|
130
|
+
"great-docs>=0.14",
|
|
135
131
|
]
|
|
@@ -24,11 +24,12 @@ from spawnllm.backends import (
|
|
|
24
24
|
LlmBackend,
|
|
25
25
|
LlmBackends,
|
|
26
26
|
MlxBackend,
|
|
27
|
+
OpenAiEndpointBackend,
|
|
27
28
|
select_backend,
|
|
28
29
|
)
|
|
29
30
|
from spawnllm.call import call, call_sync
|
|
30
31
|
from spawnllm.extract import extract, extract_sync
|
|
31
|
-
from spawnllm.response import Error, Output, Response, Result
|
|
32
|
+
from spawnllm.response import DiscardedAttempt, Error, Output, Response, Result
|
|
32
33
|
from spawnllm.run import run, run_sync
|
|
33
34
|
from spawnllm.spec import ClaudeConfig, CodexConfig, GeminiConfig, RunSpec
|
|
34
35
|
from spawnllm.types import ProviderName, TModel, TSpecialty
|
|
@@ -46,12 +47,14 @@ __all__ = [
|
|
|
46
47
|
"CliBackend",
|
|
47
48
|
"CodexCliBackend",
|
|
48
49
|
"CodexConfig",
|
|
50
|
+
"DiscardedAttempt",
|
|
49
51
|
"Error",
|
|
50
52
|
"GeminiCliBackend",
|
|
51
53
|
"GeminiConfig",
|
|
52
54
|
"LlmBackend",
|
|
53
55
|
"LlmBackends",
|
|
54
56
|
"MlxBackend",
|
|
57
|
+
"OpenAiEndpointBackend",
|
|
55
58
|
"Output",
|
|
56
59
|
"ProviderName",
|
|
57
60
|
"Response",
|
|
@@ -16,6 +16,7 @@ from spawnllm.backends.claude import ClaudeCliBackend
|
|
|
16
16
|
from spawnllm.backends.codex import CodexCliBackend
|
|
17
17
|
from spawnllm.backends.gemini import AntigravityCliBackend, GeminiCliBackend
|
|
18
18
|
from spawnllm.backends.mlx import MlxBackend
|
|
19
|
+
from spawnllm.backends.openai_endpoint import OpenAiEndpointBackend
|
|
19
20
|
from spawnllm.backends.registry import LlmBackends, select_backend
|
|
20
21
|
|
|
21
22
|
__all__ = [
|
|
@@ -33,5 +34,6 @@ __all__ = [
|
|
|
33
34
|
"LlmBackend",
|
|
34
35
|
"LlmBackends",
|
|
35
36
|
"MlxBackend",
|
|
37
|
+
"OpenAiEndpointBackend",
|
|
36
38
|
"select_backend",
|
|
37
39
|
]
|
|
@@ -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
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""HTTP backend for any OpenAI-compatible `/chat/completions` endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from spawnllm.backends.base import BackendReady, LlmBackend
|
|
11
|
+
from spawnllm.structured import extract_json_block
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from spawnllm.backends.base import BackendStatus
|
|
17
|
+
from spawnllm.response import Response
|
|
18
|
+
from spawnllm.spec import RunSpec
|
|
19
|
+
from spawnllm.types import ProviderName, TModel
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OpenAiEndpointBackend(LlmBackend):
|
|
23
|
+
"""`LlmBackend` that POSTs to an OpenAI-compatible `/chat/completions` endpoint.
|
|
24
|
+
|
|
25
|
+
Unlike the CLI backends this drives a raw `httpx` request rather than a
|
|
26
|
+
subprocess, and it is never auto-selected; the consumer constructs it
|
|
27
|
+
explicitly with a `base_url` and a literal `model`. Every abstract tier maps
|
|
28
|
+
to that one pinned `model`, and `RunSpec.model` is ignored at request time —
|
|
29
|
+
the endpoint always serves the constructor's `model`. Structured output rides
|
|
30
|
+
on `response_format` with a strict `json_schema`; text output reads
|
|
31
|
+
`choices[0].message.content`.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
base_url: Root URL of the server; `/chat/completions` is appended.
|
|
35
|
+
model: The literal model id sent in every request body.
|
|
36
|
+
api_key: Bearer token for the `Authorization` header; defaults to
|
|
37
|
+
`"local"` for self-hosted servers that ignore it.
|
|
38
|
+
transport: Async transport injected into the `httpx.AsyncClient` used by
|
|
39
|
+
`aexecute` — e.g. a record/replay caching transport; `None` uses
|
|
40
|
+
httpx's default transport. The synchronous `execute` path always uses
|
|
41
|
+
the default transport.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> backend = OpenAiEndpointBackend("http://localhost:8000/v1", "qwen3")
|
|
45
|
+
>>> backend.execute(RunSpec(prompt="ping", model="qwen3"))
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
provider: ClassVar[ProviderName] = "openai_endpoint"
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self, base_url: str, model: str, *, api_key: str = "local", transport: httpx.AsyncBaseTransport | None = None
|
|
52
|
+
) -> None:
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.model = model
|
|
55
|
+
self.api_key = api_key
|
|
56
|
+
self.transport = transport
|
|
57
|
+
self.models: dict[TModel, str] = {"small": model, "medium": model, "large": model}
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def url(self) -> str:
|
|
61
|
+
return f"{self.base_url}/chat/completions"
|
|
62
|
+
|
|
63
|
+
def headers(self) -> dict[str, str]:
|
|
64
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
65
|
+
|
|
66
|
+
def payload(self, spec: RunSpec) -> dict[str, object]:
|
|
67
|
+
base: dict[str, object] = {"model": self.model, "messages": [{"role": "user", "content": spec.prompt}]}
|
|
68
|
+
if (schema := self.schema_arg(spec)) is None:
|
|
69
|
+
return base
|
|
70
|
+
json_schema = {"name": "response", "strict": True, "schema": json.loads(schema)}
|
|
71
|
+
return base | {"response_format": {"type": "json_schema", "json_schema": json_schema}}
|
|
72
|
+
|
|
73
|
+
def resolve(self, resp: httpx.Response, spec: RunSpec) -> Response:
|
|
74
|
+
return self.to_response(
|
|
75
|
+
resp.text,
|
|
76
|
+
returncode=0 if resp.is_success else resp.status_code,
|
|
77
|
+
stderr="" if resp.is_success else resp.text,
|
|
78
|
+
spec=spec,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def aexecute(self, spec: RunSpec) -> Response:
|
|
82
|
+
async with httpx.AsyncClient(timeout=spec.timeout, transport=self.transport) as client:
|
|
83
|
+
resp = await client.post(self.url, headers=self.headers(), json=self.payload(spec))
|
|
84
|
+
return self.resolve(resp, spec)
|
|
85
|
+
|
|
86
|
+
def execute(self, spec: RunSpec) -> Response:
|
|
87
|
+
with httpx.Client(timeout=spec.timeout) as client:
|
|
88
|
+
resp = client.post(self.url, headers=self.headers(), json=self.payload(spec))
|
|
89
|
+
return self.resolve(resp, spec)
|
|
90
|
+
|
|
91
|
+
def schema_for(self, model: type[BaseModel]) -> str:
|
|
92
|
+
"""Serialize a Pydantic model into an OpenAI strict JSON schema for `response_format`.
|
|
93
|
+
|
|
94
|
+
Uses the OpenAI SDK's `to_strict_json_schema`, which recursively sets
|
|
95
|
+
`additionalProperties: false` and forces every property into `required`
|
|
96
|
+
across `$defs`, `anyOf`, and array items — the form a `json_schema`
|
|
97
|
+
`response_format` requires.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
model: The Pydantic model describing the structured output.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A strict JSON-schema string embedded in the request body.
|
|
104
|
+
"""
|
|
105
|
+
from openai.lib._pydantic import to_strict_json_schema
|
|
106
|
+
|
|
107
|
+
return json.dumps(to_strict_json_schema(model))
|
|
108
|
+
|
|
109
|
+
def result_text(self, raw: str) -> str:
|
|
110
|
+
"""Return `choices[0].message.content` from the chat-completion response body."""
|
|
111
|
+
return json.loads(raw)["choices"][0]["message"]["content"]
|
|
112
|
+
|
|
113
|
+
def result_value(self, raw: str) -> object:
|
|
114
|
+
"""Return the JSON value parsed from the message content, tolerating fences or surrounding prose."""
|
|
115
|
+
return json.loads(extract_json_block(self.result_text(raw)))
|
|
116
|
+
|
|
117
|
+
def envelope_error(self, raw: str) -> str | None:
|
|
118
|
+
"""Return the message from an OpenAI `{"error": {...}}` body carried on a 2xx response, else `None`."""
|
|
119
|
+
match json.loads(raw):
|
|
120
|
+
case {"error": {"message": str(msg)}}:
|
|
121
|
+
return msg
|
|
122
|
+
case {"error": str(msg)}:
|
|
123
|
+
return msg
|
|
124
|
+
case _:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def env(self, _spec: RunSpec) -> dict[str, str]:
|
|
128
|
+
"""Return no extra environment variables; the endpoint is reached over HTTP with nothing to isolate."""
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
def is_authenticated(self, *, timeout: int) -> bool:
|
|
132
|
+
"""Report `True`; credentials travel inline as the `Authorization` bearer token on every request."""
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def check_status(self, *, timeout: int = 10) -> BackendStatus:
|
|
136
|
+
"""Report `BackendReady`; the endpoint is reached per-request and carries its own auth."""
|
|
137
|
+
return BackendReady(binary="openai_endpoint")
|
|
@@ -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
|
|
@@ -12,5 +12,5 @@ TSpecialty = Literal["debugging", "review", "general"]
|
|
|
12
12
|
TModel = Literal["small", "medium", "large"]
|
|
13
13
|
"""Abstract model tier; each backend maps it to a provider-specific model name."""
|
|
14
14
|
|
|
15
|
-
ProviderName = Literal["claude", "codex", "gemini", "antigravity", "mlx"]
|
|
15
|
+
ProviderName = Literal["claude", "codex", "gemini", "antigravity", "mlx", "openai_endpoint"]
|
|
16
16
|
"""Backend provider identifier; keys the per-backend `provider_configs` on a `RunSpec`."""
|
|
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
|