spawnllm 0.5.0__tar.gz → 0.5.2__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.0 → spawnllm-0.5.2}/PKG-INFO +1 -1
- {spawnllm-0.5.0 → spawnllm-0.5.2}/pyproject.toml +1 -1
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/__init__.py +4 -1
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/base.py +72 -37
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/claude.py +53 -7
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/codex.py +15 -7
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/gemini.py +7 -2
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/mlx.py +2 -2
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/registry.py +5 -2
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/call.py +4 -5
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/extract.py +4 -5
- spawnllm-0.5.2/spawnllm/response.py +78 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/run.py +3 -2
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/spec.py +12 -1
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/structured.py +2 -2
- spawnllm-0.5.0/spawnllm/response.py +0 -29
- {spawnllm-0.5.0 → spawnllm-0.5.2}/LICENSE +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/README.md +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/__main__.py +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/__init__.py +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/cli.py +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/mlx/engine.py +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/proc.py +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/py.typed +0 -0
- {spawnllm-0.5.0 → spawnllm-0.5.2}/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.5.
|
|
4
|
+
version = "0.5.2"
|
|
5
5
|
description = "Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools."
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "MIT"
|
|
@@ -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 Response
|
|
31
|
+
from spawnllm.response import 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,13 +46,16 @@ __all__ = [
|
|
|
46
46
|
"CliBackend",
|
|
47
47
|
"CodexCliBackend",
|
|
48
48
|
"CodexConfig",
|
|
49
|
+
"Error",
|
|
49
50
|
"GeminiCliBackend",
|
|
50
51
|
"GeminiConfig",
|
|
51
52
|
"LlmBackend",
|
|
52
53
|
"LlmBackends",
|
|
53
54
|
"MlxBackend",
|
|
55
|
+
"Output",
|
|
54
56
|
"ProviderName",
|
|
55
57
|
"Response",
|
|
58
|
+
"Result",
|
|
56
59
|
"RunSpec",
|
|
57
60
|
"TModel",
|
|
58
61
|
"TSpecialty",
|
|
@@ -5,13 +5,14 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import shutil
|
|
8
|
+
import subprocess
|
|
8
9
|
from abc import ABC, abstractmethod
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import TYPE_CHECKING, ClassVar
|
|
12
13
|
|
|
13
14
|
from spawnllm.proc import acapture_cli, capture_cli
|
|
14
|
-
from spawnllm.response import Response
|
|
15
|
+
from spawnllm.response import Error, Output, Response, Result
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
18
|
from pydantic import BaseModel
|
|
@@ -66,14 +67,9 @@ class BackendUnavailable(RuntimeError):
|
|
|
66
67
|
class BackendCallError(RuntimeError):
|
|
67
68
|
"""Raised by `call`/`extract` when a backend returns a provider error.
|
|
68
69
|
|
|
69
|
-
Carries the backend's error string
|
|
70
|
-
envelope), attached both as the message and as a note for tracebacks.
|
|
70
|
+
Carries the backend's error string: a nonzero exit with stderr, or an error envelope.
|
|
71
71
|
"""
|
|
72
72
|
|
|
73
|
-
def __init__(self, error: str) -> None:
|
|
74
|
-
super().__init__(error)
|
|
75
|
-
self.add_note(error)
|
|
76
|
-
|
|
77
73
|
|
|
78
74
|
@dataclass(frozen=True)
|
|
79
75
|
class Invocation:
|
|
@@ -133,8 +129,13 @@ class LlmBackend(ABC):
|
|
|
133
129
|
"""
|
|
134
130
|
|
|
135
131
|
@abstractmethod
|
|
136
|
-
def env(self) -> dict[str, str]:
|
|
137
|
-
"""Return extra environment variables for the invocation, merged over the inherited environment.
|
|
132
|
+
def env(self, spec: RunSpec) -> dict[str, str]:
|
|
133
|
+
"""Return extra environment variables for the invocation, merged over the inherited environment.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
spec: The configured run, so a backend can scope env overrides to
|
|
137
|
+
`spec.isolated` (e.g. a fresh config home only when isolating).
|
|
138
|
+
"""
|
|
138
139
|
|
|
139
140
|
@abstractmethod
|
|
140
141
|
def is_authenticated(self, *, timeout: int) -> bool:
|
|
@@ -177,35 +178,59 @@ class LlmBackend(ABC):
|
|
|
177
178
|
return json.dumps(model.model_json_schema())
|
|
178
179
|
|
|
179
180
|
def schema_arg(self, spec: RunSpec) -> str | None:
|
|
180
|
-
"""Return the JSON-schema string for `spec
|
|
181
|
-
|
|
181
|
+
"""Return the JSON-schema string for `spec`, from a `response_model` or a raw `schema`.
|
|
182
|
+
|
|
183
|
+
A `response_model` is run through `schema_for` (the provider's
|
|
184
|
+
strict-schema transform); a raw `schema` passes verbatim — a dict is
|
|
185
|
+
`json.dumps`'d, a string is returned unchanged. Returns `None` when
|
|
186
|
+
neither is set.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
spec: The configured run, carrying the optional `response_model` or `schema`.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The JSON-schema string for this backend's structured-output argument, or `None`.
|
|
193
|
+
"""
|
|
194
|
+
if spec.response_model is not None:
|
|
195
|
+
return self.schema_for(spec.response_model)
|
|
196
|
+
if spec.schema is not None:
|
|
197
|
+
return json.dumps(spec.schema) if isinstance(spec.schema, dict) else spec.schema
|
|
198
|
+
return None
|
|
182
199
|
|
|
183
200
|
def to_response(self, raw: str, *, returncode: int, stderr: str, spec: RunSpec) -> Response:
|
|
184
|
-
"""Resolve a raw capture into a `Response`: detect failure, extract text, validate.
|
|
201
|
+
"""Resolve a raw capture into a structured `Response`: detect failure, extract text, validate.
|
|
185
202
|
|
|
186
|
-
A nonzero exit
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
203
|
+
`output` always carries the full raw stream. A nonzero exit, an error
|
|
204
|
+
envelope, or a `pydantic.ValidationError` from a non-conforming model all
|
|
205
|
+
route through `error` (with the underlying exception preserved in
|
|
206
|
+
`error.ex`) and leave `result` as `None`; a success yields `result` (text
|
|
207
|
+
from `result_text`, plus the validated model from `result_value` when
|
|
208
|
+
`spec.response_model` is set) and `error` as `None`.
|
|
190
209
|
|
|
191
210
|
Args:
|
|
192
211
|
raw: The raw output read wherever the provider wrote it.
|
|
193
212
|
returncode: The process exit code.
|
|
194
213
|
stderr: The captured stderr.
|
|
195
|
-
spec: The configured run, carrying the optional `response_model`.
|
|
214
|
+
spec: The configured run, carrying the optional `response_model` or `schema`.
|
|
196
215
|
|
|
197
216
|
Returns:
|
|
198
217
|
The resolved `Response`.
|
|
199
218
|
"""
|
|
219
|
+
import pydantic
|
|
220
|
+
|
|
221
|
+
output = Output(raw)
|
|
200
222
|
if returncode != 0:
|
|
201
|
-
|
|
223
|
+
msg = f"{self.provider} exited {returncode}: {stderr.strip()[-2000:]}"
|
|
224
|
+
return Response(spec=spec, output=output, error=Error(msg, BackendCallError(msg)))
|
|
202
225
|
if (err := self.envelope_error(raw)) is not None:
|
|
203
|
-
return Response(error=err,
|
|
226
|
+
return Response(spec=spec, output=output, error=Error(err, BackendCallError(err)))
|
|
204
227
|
if spec.response_model is None:
|
|
205
|
-
return Response(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
228
|
+
return Response(spec=spec, output=output, result=Result(raw=self.result_text(raw)))
|
|
229
|
+
try:
|
|
230
|
+
parsed = spec.response_model.model_validate(self.result_value(raw))
|
|
231
|
+
except pydantic.ValidationError as e:
|
|
232
|
+
return Response(spec=spec, output=output, error=Error(str(e), e))
|
|
233
|
+
return Response(spec=spec, output=output, result=Result(raw=self.result_text(raw), parsed=parsed))
|
|
209
234
|
|
|
210
235
|
def result_text(self, raw: str) -> str:
|
|
211
236
|
"""Return the final text output from a raw capture; the default is `raw` unchanged."""
|
|
@@ -261,16 +286,23 @@ class CliBackend(LlmBackend):
|
|
|
261
286
|
"""
|
|
262
287
|
return Invocation(self.build_command(spec), spec.prompt)
|
|
263
288
|
|
|
289
|
+
def timed_out(self, spec: RunSpec) -> Response:
|
|
290
|
+
msg = f"{self.provider} timed out after {spec.timeout}s"
|
|
291
|
+
return Response(spec=spec, output=Output(""), error=Error(msg, TimeoutError(msg)))
|
|
292
|
+
|
|
264
293
|
async def aexecute(self, spec: RunSpec) -> Response:
|
|
265
294
|
inv = self.invocation(spec)
|
|
266
295
|
try:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
296
|
+
try:
|
|
297
|
+
rr = await acapture_cli(
|
|
298
|
+
inv.argv,
|
|
299
|
+
input=inv.stdin,
|
|
300
|
+
env=os.environ | self.env(spec) | (spec.env or {}),
|
|
301
|
+
cwd=spec.cwd,
|
|
302
|
+
timeout=spec.timeout,
|
|
303
|
+
)
|
|
304
|
+
except TimeoutError:
|
|
305
|
+
return self.timed_out(spec)
|
|
274
306
|
raw = Path(inv.result_path).read_text() if inv.result_path else rr.stdout
|
|
275
307
|
finally:
|
|
276
308
|
for path in inv.cleanup_paths:
|
|
@@ -280,13 +312,16 @@ class CliBackend(LlmBackend):
|
|
|
280
312
|
def execute(self, spec: RunSpec) -> Response:
|
|
281
313
|
inv = self.invocation(spec)
|
|
282
314
|
try:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
315
|
+
try:
|
|
316
|
+
rr = capture_cli(
|
|
317
|
+
inv.argv,
|
|
318
|
+
input=inv.stdin,
|
|
319
|
+
env=os.environ | self.env(spec) | (spec.env or {}),
|
|
320
|
+
cwd=spec.cwd,
|
|
321
|
+
timeout=spec.timeout,
|
|
322
|
+
)
|
|
323
|
+
except subprocess.TimeoutExpired:
|
|
324
|
+
return self.timed_out(spec)
|
|
290
325
|
raw = Path(inv.result_path).read_text() if inv.result_path else rr.stdout
|
|
291
326
|
finally:
|
|
292
327
|
for path in inv.cleanup_paths:
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import atexit
|
|
5
6
|
import json
|
|
7
|
+
import shutil
|
|
6
8
|
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
7
11
|
from typing import TYPE_CHECKING, ClassVar
|
|
8
12
|
|
|
9
13
|
from spawnllm.backends.base import CliBackend
|
|
@@ -58,6 +62,8 @@ class ClaudeCliBackend(CliBackend):
|
|
|
58
62
|
binary: ClassVar[str] = "claude"
|
|
59
63
|
install_hint: ClassVar[str] = "curl -fsSL https://claude.ai/install.sh | bash"
|
|
60
64
|
|
|
65
|
+
_isolated_config_dir: str | None = None
|
|
66
|
+
|
|
61
67
|
def build_command(self, spec: RunSpec) -> list[str]:
|
|
62
68
|
"""Build the `claude -p` argv for one stdin-prompted invocation.
|
|
63
69
|
|
|
@@ -84,11 +90,12 @@ class ClaudeCliBackend(CliBackend):
|
|
|
84
90
|
"--no-session-persistence",
|
|
85
91
|
"--model",
|
|
86
92
|
spec.model,
|
|
93
|
+
*(["--setting-sources", ""] if spec.isolated else []),
|
|
94
|
+
*(["--strict-mcp-config"] if spec.isolated or cfg.strict_mcp else []),
|
|
87
95
|
*(
|
|
88
96
|
[
|
|
89
97
|
*(["--permission-mode", cfg.permission_mode] if cfg.permission_mode is not None else []),
|
|
90
98
|
*(["--mcp-config", cfg.mcp_config] if cfg.mcp_config is not None else []),
|
|
91
|
-
*(["--strict-mcp-config"] if cfg.strict_mcp else []),
|
|
92
99
|
*(["--disallowedTools", *cfg.disallowed_tools] if cfg.disallowed_tools else []),
|
|
93
100
|
*(
|
|
94
101
|
["--append-system-prompt", cfg.append_system_prompt]
|
|
@@ -101,7 +108,7 @@ class ClaudeCliBackend(CliBackend):
|
|
|
101
108
|
if explicit
|
|
102
109
|
else ["--permission-mode", "auto", "--max-budget-usd", "1"]
|
|
103
110
|
if spec.agent
|
|
104
|
-
else ["--system-prompt", ""
|
|
111
|
+
else ["--system-prompt", ""]
|
|
105
112
|
),
|
|
106
113
|
*(["--system-prompt", cfg.system_prompt] if cfg.system_prompt is not None else []),
|
|
107
114
|
*(["--max-turns", str(cfg.max_turns)] if cfg.max_turns is not None else []),
|
|
@@ -150,11 +157,50 @@ class ClaudeCliBackend(CliBackend):
|
|
|
150
157
|
return event["result"] if isinstance(event.get("result"), str) else "claude reported an error"
|
|
151
158
|
return None
|
|
152
159
|
|
|
153
|
-
def env(self) -> dict[str, str]:
|
|
154
|
-
"""
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
def env(self, spec: RunSpec) -> dict[str, str]:
|
|
161
|
+
"""Point an isolated run at a fresh, host-free `CLAUDE_CONFIG_DIR`; otherwise add nothing.
|
|
162
|
+
|
|
163
|
+
Defense in depth behind the argv flags: a config home seeded with nothing
|
|
164
|
+
but the account pointer and OAuth token means plugin and
|
|
165
|
+
`~/.claude.json`-driven loading finds no host settings, plugins, or hooks
|
|
166
|
+
even if a flag is ever dropped.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
spec: The configured run; `spec.isolated` gates the override.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
`{"CLAUDE_CONFIG_DIR": <isolated dir>}` for an isolated run, else `{}`.
|
|
173
|
+
"""
|
|
174
|
+
if not spec.isolated:
|
|
175
|
+
return {}
|
|
176
|
+
return {"CLAUDE_CONFIG_DIR": self._isolated_dir()}
|
|
177
|
+
|
|
178
|
+
def _isolated_dir(self) -> str:
|
|
179
|
+
"""Return the process-lifetime isolated config home, creating and seeding it once.
|
|
180
|
+
|
|
181
|
+
The home is a fresh temp dir seeded with only the two auth-bearing files:
|
|
182
|
+
`~/.claude.json` minus its `mcpServers` block (the active-account pointer,
|
|
183
|
+
with no host MCP servers leaking even absent `--strict-mcp-config`) and
|
|
184
|
+
`~/.claude/.credentials.json` (the claude.ai OAuth token, which lives in
|
|
185
|
+
the config home — relocating it without this copy logs the run out). Host
|
|
186
|
+
`settings.json`, plugins, and hooks are never copied. The dir is cached on
|
|
187
|
+
the backend and removed at interpreter exit.
|
|
188
|
+
"""
|
|
189
|
+
if self._isolated_config_dir is not None:
|
|
190
|
+
return self._isolated_config_dir
|
|
191
|
+
config_dir = Path(tempfile.mkdtemp(prefix="spawnllm-claude-config-"))
|
|
192
|
+
home = Path.home()
|
|
193
|
+
account_path = home / ".claude.json"
|
|
194
|
+
if account_path.exists():
|
|
195
|
+
account = json.loads(account_path.read_text())
|
|
196
|
+
account.pop("mcpServers", None)
|
|
197
|
+
(config_dir / ".claude.json").write_text(json.dumps(account))
|
|
198
|
+
credentials_path = home / ".claude" / ".credentials.json"
|
|
199
|
+
if credentials_path.exists():
|
|
200
|
+
shutil.copyfile(credentials_path, config_dir / ".credentials.json")
|
|
201
|
+
atexit.register(shutil.rmtree, config_dir, ignore_errors=True)
|
|
202
|
+
self._isolated_config_dir = str(config_dir)
|
|
203
|
+
return self._isolated_config_dir
|
|
158
204
|
|
|
159
205
|
def is_authenticated(self, *, timeout: int) -> bool:
|
|
160
206
|
"""Report whether `claude auth status` exits cleanly, i.e. a claude.ai login is stored.
|
|
@@ -36,9 +36,9 @@ class CodexCliBackend(CliBackend):
|
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
38
|
models: ClassVar[dict[TModel, str]] = {
|
|
39
|
-
"small": "gpt-5.
|
|
40
|
-
"medium": "gpt-5.4-mini",
|
|
41
|
-
"large": "gpt-5.5",
|
|
39
|
+
"small": "gpt-5.4-mini:low",
|
|
40
|
+
"medium": "gpt-5.4-mini:medium",
|
|
41
|
+
"large": "gpt-5.5:medium",
|
|
42
42
|
}
|
|
43
43
|
provider: ClassVar[ProviderName] = "codex"
|
|
44
44
|
binary: ClassVar[str] = "codex"
|
|
@@ -63,6 +63,7 @@ class CodexCliBackend(CliBackend):
|
|
|
63
63
|
|
|
64
64
|
def command_for(self, spec: RunSpec, schema_path: str | None) -> list[str]:
|
|
65
65
|
cfg = spec.config_for(CodexConfig) or CodexConfig()
|
|
66
|
+
model, _, effort = spec.model.partition(":")
|
|
66
67
|
return [
|
|
67
68
|
"codex",
|
|
68
69
|
"exec",
|
|
@@ -70,12 +71,14 @@ class CodexCliBackend(CliBackend):
|
|
|
70
71
|
"--sandbox",
|
|
71
72
|
cfg.sandbox or "read-only",
|
|
72
73
|
"--model",
|
|
73
|
-
|
|
74
|
+
model,
|
|
75
|
+
*(["-c", f"model_reasoning_effort={effort}"] if effort else []),
|
|
76
|
+
*(["--ignore-user-config"] if spec.isolated else []),
|
|
74
77
|
*(
|
|
75
78
|
[]
|
|
76
79
|
if spec.agent
|
|
77
80
|
else [
|
|
78
|
-
*([] if cfg.enable_hooks else ["-c", "features.
|
|
81
|
+
*([] if cfg.enable_hooks else ["-c", "features.hooks=false"]),
|
|
79
82
|
*([] if cfg.enable_mcp else ["-c", "features.mcp_servers=false"]),
|
|
80
83
|
]
|
|
81
84
|
),
|
|
@@ -125,8 +128,13 @@ class CodexCliBackend(CliBackend):
|
|
|
125
128
|
|
|
126
129
|
return json.dumps(to_strict_json_schema(model))
|
|
127
130
|
|
|
128
|
-
def env(self) -> dict[str, str]:
|
|
129
|
-
"""Return no extra environment variables;
|
|
131
|
+
def env(self, _spec: RunSpec) -> dict[str, str]:
|
|
132
|
+
"""Return no extra environment variables; `--ignore-user-config` isolates config while `CODEX_HOME` keeps auth.
|
|
133
|
+
|
|
134
|
+
`codex` keeps `auth.json` in `CODEX_HOME`, so relocating it would strand a
|
|
135
|
+
single-use OAuth refresh token; the `--ignore-user-config` flag isolates
|
|
136
|
+
`config.toml` without touching auth.
|
|
137
|
+
"""
|
|
130
138
|
return {}
|
|
131
139
|
|
|
132
140
|
def is_authenticated(self, *, timeout: int) -> bool:
|
|
@@ -30,8 +30,13 @@ class GeminiFamilyBackend(CliBackend, ABC):
|
|
|
30
30
|
|
|
31
31
|
api_key_envs: ClassVar[tuple[str, ...]]
|
|
32
32
|
|
|
33
|
-
def env(self) -> dict[str, str]:
|
|
34
|
-
"""Return no extra environment variables
|
|
33
|
+
def env(self, _spec: RunSpec) -> dict[str, str]:
|
|
34
|
+
"""Return no extra environment variables.
|
|
35
|
+
|
|
36
|
+
Gemini-family CLIs read settings and OAuth from the same config home (`~/.gemini`,
|
|
37
|
+
`~/.gemini/antigravity-cli`), with no isolation flag and no way to relocate config
|
|
38
|
+
without stranding auth, so isolated runs read the real config — the no-flag exception.
|
|
39
|
+
"""
|
|
35
40
|
return {}
|
|
36
41
|
|
|
37
42
|
def is_authenticated(self, *, timeout: int) -> bool:
|
|
@@ -52,8 +52,8 @@ class MlxBackend(LlmBackend):
|
|
|
52
52
|
"""Return the `structured_output` from a stream-json result event, else `raw` parsed as JSON."""
|
|
53
53
|
return structured_value(raw)
|
|
54
54
|
|
|
55
|
-
def env(self) -> dict[str, str]:
|
|
56
|
-
"""Return no extra environment variables; MLX runs in-process."""
|
|
55
|
+
def env(self, _spec: RunSpec) -> dict[str, str]:
|
|
56
|
+
"""Return no extra environment variables; MLX runs in-process with nothing to isolate."""
|
|
57
57
|
return {}
|
|
58
58
|
|
|
59
59
|
def is_authenticated(self, *, timeout: int) -> bool:
|
|
@@ -55,7 +55,9 @@ def select_backend(*, specialty: TSpecialty | None = None, timeout: int = 10) ->
|
|
|
55
55
|
"""Return the first installed, authenticated backend in priority order.
|
|
56
56
|
|
|
57
57
|
A `specialty`, when given, promotes its registered backend to the front of
|
|
58
|
-
the chain; the chain otherwise follows `PRIORITY
|
|
58
|
+
the chain; the chain otherwise follows `PRIORITY`, minus `GeminiCliBackend`
|
|
59
|
+
(its Code Assist OAuth tier is retired, so it reports ready yet fails at call
|
|
60
|
+
time — reach it only via an explicit `backend=`). The first backend whose
|
|
59
61
|
`check_status` reports `BackendReady` wins, short-circuiting the rest;
|
|
60
62
|
backends that time out are skipped.
|
|
61
63
|
|
|
@@ -71,7 +73,8 @@ def select_backend(*, specialty: TSpecialty | None = None, timeout: int = 10) ->
|
|
|
71
73
|
"""
|
|
72
74
|
preferred = [LlmBackends.LLM_BACKENDS[specialty]] if specialty else []
|
|
73
75
|
seen = {type(b) for b in preferred}
|
|
74
|
-
|
|
76
|
+
auto = (b for b in PRIORITY if type(b) not in seen and not isinstance(b, GeminiCliBackend))
|
|
77
|
+
for backend in (*preferred, *auto):
|
|
75
78
|
try:
|
|
76
79
|
if isinstance(backend.check_status(timeout=timeout), BackendReady):
|
|
77
80
|
return backend
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
-
from spawnllm.backends.base import BackendCallError
|
|
8
7
|
from spawnllm.backends.registry import select_backend
|
|
9
8
|
from spawnllm.run import run, run_sync
|
|
10
9
|
from spawnllm.spec import RunSpec
|
|
@@ -54,8 +53,8 @@ async def call(
|
|
|
54
53
|
RunSpec(prompt=prompt, model=backend.models[model], agent=agent, cwd=cwd, timeout=timeout), backend=backend
|
|
55
54
|
)
|
|
56
55
|
if resp.error is not None:
|
|
57
|
-
raise
|
|
58
|
-
return resp.result
|
|
56
|
+
raise resp.error.ex
|
|
57
|
+
return resp.result.raw
|
|
59
58
|
|
|
60
59
|
|
|
61
60
|
def call_sync(
|
|
@@ -96,5 +95,5 @@ def call_sync(
|
|
|
96
95
|
RunSpec(prompt=prompt, model=backend.models[model], agent=agent, cwd=cwd, timeout=timeout), backend=backend
|
|
97
96
|
)
|
|
98
97
|
if resp.error is not None:
|
|
99
|
-
raise
|
|
100
|
-
return resp.result
|
|
98
|
+
raise resp.error.ex
|
|
99
|
+
return resp.result.raw
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING, cast
|
|
6
6
|
|
|
7
|
-
from spawnllm.backends.base import BackendCallError
|
|
8
7
|
from spawnllm.backends.registry import select_backend
|
|
9
8
|
from spawnllm.run import run, run_sync
|
|
10
9
|
from spawnllm.spec import RunSpec
|
|
@@ -66,8 +65,8 @@ async def extract[T: BaseModel](
|
|
|
66
65
|
)
|
|
67
66
|
resp = await run(spec, backend=backend)
|
|
68
67
|
if resp.error is not None:
|
|
69
|
-
raise
|
|
70
|
-
return cast(T, resp.parsed)
|
|
68
|
+
raise resp.error.ex
|
|
69
|
+
return cast(T, resp.result.parsed)
|
|
71
70
|
|
|
72
71
|
|
|
73
72
|
def extract_sync[T: BaseModel](
|
|
@@ -118,5 +117,5 @@ def extract_sync[T: BaseModel](
|
|
|
118
117
|
)
|
|
119
118
|
resp = run_sync(spec, backend=backend)
|
|
120
119
|
if resp.error is not None:
|
|
121
|
-
raise
|
|
122
|
-
return cast(T, resp.parsed)
|
|
120
|
+
raise resp.error.ex
|
|
121
|
+
return cast(T, resp.result.parsed)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The structured object every backend hands back: spec, raw output, result, and error."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from spawnllm.spec import RunSpec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Error:
|
|
16
|
+
"""A failed run: a human-readable message plus the underlying exception.
|
|
17
|
+
|
|
18
|
+
`ex` preserves the real exception so a caller can re-raise it unchanged —
|
|
19
|
+
a `BackendCallError` for a nonzero exit or error envelope, a normalized
|
|
20
|
+
timeout error, or a `pydantic.ValidationError` for a non-conforming model.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
>>> Error(msg="codex exited 127: codex: not found", ex=RuntimeError("..."))
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
msg: str
|
|
27
|
+
ex: Exception
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Result:
|
|
32
|
+
"""A successful run: the extracted final text and the optional validated model.
|
|
33
|
+
|
|
34
|
+
`raw` is the extracted final text; `parsed` is the validated model, set only
|
|
35
|
+
when the `RunSpec` carried a `response_model`.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> Result(raw="hello", parsed=None)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
raw: str
|
|
42
|
+
parsed: BaseModel | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Output:
|
|
47
|
+
"""The full unparsed transport stream, present on success and failure alike.
|
|
48
|
+
|
|
49
|
+
`raw` is the complete output the provider wrote — the `claude
|
|
50
|
+
--output-format json` event stream, the `codex -o` file, or plain stdout —
|
|
51
|
+
before any extraction.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
>>> Output(raw='[{"type": "system"}, {"type": "result", "result": "hi"}]')
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
raw: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Response:
|
|
62
|
+
"""A backend's fully-resolved outcome: the spec, the raw output, and exactly one of result/error.
|
|
63
|
+
|
|
64
|
+
A backend runs the process, reads its output wherever the provider writes it,
|
|
65
|
+
detects failure, and validates — then hands back one `Response`. `spec` and
|
|
66
|
+
`output` are always present (the raw bytes live in `output.raw` even on
|
|
67
|
+
failure); exactly one of `result`/`error` is set. Every failure — a nonzero
|
|
68
|
+
exit, an error envelope, a timeout, or a validation error — routes through
|
|
69
|
+
`error`, never a raise from `run`.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
>>> Response(spec=spec, output=Output(raw="hi"), result=Result(raw="hi"))
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
spec: RunSpec
|
|
76
|
+
output: Output
|
|
77
|
+
result: Result | None = None
|
|
78
|
+
error: Error | None = None
|
|
@@ -23,8 +23,9 @@ async def run(spec: RunSpec, *, backend: LlmBackend | None = None) -> Response:
|
|
|
23
23
|
Each attempt runs through `backend.aexecute`; a transient `Response.error`
|
|
24
24
|
(a 529, overloaded, rate-limit, or `5xx`) triggers a capped exponential
|
|
25
25
|
backoff and another attempt, up to `spec.max_attempts`. The final `Response`
|
|
26
|
-
— success or last
|
|
27
|
-
|
|
26
|
+
— success or last failure — is returned without raising; every operational
|
|
27
|
+
failure (nonzero exit, error envelope, timeout, validation) lives in
|
|
28
|
+
`resp.error`.
|
|
28
29
|
|
|
29
30
|
Args:
|
|
30
31
|
spec: The configured run to execute.
|
|
@@ -75,7 +75,12 @@ class RunSpec:
|
|
|
75
75
|
Common fields are interpreted by every backend; `provider_configs` carries
|
|
76
76
|
optional per-provider flag passthrough that only the matching backend reads.
|
|
77
77
|
`model` is a literal provider model id (`opus`, `sonnet`, …) passed straight
|
|
78
|
-
through with no tier mapping.
|
|
78
|
+
through with no tier mapping. `isolated` (default `True`) runs the backend
|
|
79
|
+
against a fresh, host-free config home so a spawned CLI ignores ambient
|
|
80
|
+
settings, MCP servers, and hooks. Structured output comes from either a
|
|
81
|
+
`response_model` (validated to a model) or a raw `schema` (a JSON-Schema dict
|
|
82
|
+
or pre-serialized string, passed to the provider verbatim, with nothing to
|
|
83
|
+
validate); setting both raises `ValueError`.
|
|
79
84
|
|
|
80
85
|
Example:
|
|
81
86
|
>>> RunSpec(prompt="ping", model="opus")
|
|
@@ -84,13 +89,19 @@ class RunSpec:
|
|
|
84
89
|
prompt: str
|
|
85
90
|
model: str
|
|
86
91
|
response_model: type[BaseModel] | None = None
|
|
92
|
+
schema: dict[str, object] | str | None = None
|
|
87
93
|
agent: bool = False
|
|
94
|
+
isolated: bool = True
|
|
88
95
|
cwd: str | None = None
|
|
89
96
|
env: dict[str, str] | None = None
|
|
90
97
|
timeout: int = 180
|
|
91
98
|
max_attempts: int = 5
|
|
92
99
|
provider_configs: dict[ProviderName, ProviderConfig] = field(default_factory=dict)
|
|
93
100
|
|
|
101
|
+
def __post_init__(self) -> None:
|
|
102
|
+
if self.response_model is not None and self.schema is not None:
|
|
103
|
+
raise ValueError("RunSpec accepts either response_model or schema, not both")
|
|
104
|
+
|
|
94
105
|
def config_for[T: ProviderConfig](self, kind: type[T]) -> T | None:
|
|
95
106
|
"""Return the first provider config that is an instance of `kind`, or None."""
|
|
96
107
|
return next((c for c in self.provider_configs.values() if isinstance(c, kind)), None)
|
|
@@ -92,7 +92,7 @@ def structured_value(raw: str) -> object:
|
|
|
92
92
|
def is_transient(resp: Response) -> bool:
|
|
93
93
|
"""Report whether a response failed with a retryable transient error.
|
|
94
94
|
|
|
95
|
-
A response is transient iff it carries an `error` whose
|
|
95
|
+
A response is transient iff it carries an `error` whose message matches the
|
|
96
96
|
`TRANSIENT` pattern (529, overloaded, rate-limit, or any `5xx`).
|
|
97
97
|
|
|
98
98
|
Args:
|
|
@@ -101,7 +101,7 @@ def is_transient(resp: Response) -> bool:
|
|
|
101
101
|
Returns:
|
|
102
102
|
`True` when the response should be retried.
|
|
103
103
|
"""
|
|
104
|
-
return resp.error is not None and bool(TRANSIENT.search(resp.error))
|
|
104
|
+
return resp.error is not None and bool(TRANSIENT.search(resp.error.msg))
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
def backoff(attempt: int) -> float:
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
"""The single object every backend hands back: error, text, and validated model."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
7
|
-
|
|
8
|
-
if TYPE_CHECKING:
|
|
9
|
-
from pydantic import BaseModel
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@dataclass(frozen=True, slots=True)
|
|
13
|
-
class Response:
|
|
14
|
-
"""A backend's fully-resolved outcome: the provider error, the text, and the model.
|
|
15
|
-
|
|
16
|
-
A backend runs the process, reads its output wherever the provider writes it,
|
|
17
|
-
detects failure, and validates — then hands back one `Response`. No
|
|
18
|
-
subprocess plumbing escapes: `error` carries the provider failure (a nonzero
|
|
19
|
-
exit with stderr, or an error envelope) and is `None` on success; `result`
|
|
20
|
-
is the final text output; `parsed` is the validated model, set only when the
|
|
21
|
-
`RunSpec` carried a `response_model`.
|
|
22
|
-
|
|
23
|
-
Example:
|
|
24
|
-
>>> Response(error=None, result="hello", parsed=None)
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
error: str | None
|
|
28
|
-
result: str | None
|
|
29
|
-
parsed: BaseModel | None = None
|
|
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
|