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.
Files changed (29) hide show
  1. {spawnllm-0.5.5 → spawnllm-0.6.1}/PKG-INFO +2 -1
  2. {spawnllm-0.5.5 → spawnllm-0.6.1}/pyproject.toml +3 -7
  3. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/__init__.py +4 -1
  4. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/__init__.py +2 -0
  5. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/base.py +21 -0
  6. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/claude.py +34 -2
  7. spawnllm-0.6.1/spawnllm/backends/openai_endpoint.py +137 -0
  8. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/proc.py +71 -29
  9. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/response.py +25 -1
  10. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/run.py +33 -6
  11. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/types.py +1 -1
  12. {spawnllm-0.5.5 → spawnllm-0.6.1}/LICENSE +0 -0
  13. {spawnllm-0.5.5 → spawnllm-0.6.1}/README.md +0 -0
  14. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/__main__.py +0 -0
  15. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/codex.py +0 -0
  16. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/gemini.py +0 -0
  17. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/mlx.py +0 -0
  18. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/backends/registry.py +0 -0
  19. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/call.py +0 -0
  20. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/cli.py +0 -0
  21. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/extract.py +0 -0
  22. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/mlx/__init__.py +0 -0
  23. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/mlx/codec.py +0 -0
  24. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/mlx/engine.py +0 -0
  25. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/mlx/fuse.py +0 -0
  26. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/mlx/patches.py +0 -0
  27. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/py.typed +0 -0
  28. {spawnllm-0.5.5 → spawnllm-0.6.1}/spawnllm/spec.py +0 -0
  29. {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.5.5
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.5.5"
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
- # 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",
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
- 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
@@ -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