spawnllm 0.7.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {spawnllm-0.7.0 → spawnllm-0.9.0}/PKG-INFO +15 -3
  2. {spawnllm-0.7.0 → spawnllm-0.9.0}/README.md +11 -0
  3. {spawnllm-0.7.0 → spawnllm-0.9.0}/pyproject.toml +8 -6
  4. spawnllm-0.9.0/spawnllm/_core/__init__.py +166 -0
  5. spawnllm-0.9.0/spawnllm/_core/spawnllm_core.wasm +0 -0
  6. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/backends/base.py +188 -65
  7. spawnllm-0.9.0/spawnllm/backends/claude.py +102 -0
  8. spawnllm-0.9.0/spawnllm/backends/codex.py +39 -0
  9. spawnllm-0.9.0/spawnllm/backends/gemini.py +63 -0
  10. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/backends/mlx.py +16 -2
  11. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/backends/openai_endpoint.py +13 -61
  12. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/cli.py +2 -0
  13. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/run.py +27 -17
  14. spawnllm-0.9.0/spawnllm/structured.py +48 -0
  15. spawnllm-0.7.0/spawnllm/backends/claude.py +0 -276
  16. spawnllm-0.7.0/spawnllm/backends/codex.py +0 -172
  17. spawnllm-0.7.0/spawnllm/backends/gemini.py +0 -230
  18. spawnllm-0.7.0/spawnllm/structured.py +0 -116
  19. {spawnllm-0.7.0 → spawnllm-0.9.0}/LICENSE +0 -0
  20. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/__init__.py +0 -0
  21. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/__main__.py +0 -0
  22. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/backends/__init__.py +0 -0
  23. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/backends/registry.py +0 -0
  24. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/call.py +0 -0
  25. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/extract.py +0 -0
  26. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/mlx/__init__.py +0 -0
  27. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/mlx/codec.py +0 -0
  28. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/mlx/engine.py +0 -0
  29. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/mlx/fuse.py +0 -0
  30. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/mlx/patches.py +0 -0
  31. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/proc.py +0 -0
  32. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/py.typed +0 -0
  33. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/response.py +0 -0
  34. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/spec.py +0 -0
  35. {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spawnllm
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Delete your subprocess wrappers around claude, codex, and gemini.
5
5
  Keywords:
6
6
  Author: Yasyf Mohamedali
@@ -17,8 +17,7 @@ Requires-Dist: click>=8
17
17
  Requires-Dist: httpx>=0.27
18
18
  Requires-Dist: loguru>=0.7
19
19
  Requires-Dist: pydantic>=2
20
- Requires-Dist: openai>=2.43
21
- Requires-Dist: anthropic>=0.111
20
+ Requires-Dist: wasmtime>=46
22
21
  Requires-Dist: zstandard>=0.25.0 ; extra == 'adapter'
23
22
  Requires-Dist: numpy>=1.26 ; extra == 'adapter'
24
23
  Requires-Dist: orjson>=3.10 ; extra == 'adapter'
@@ -29,6 +28,8 @@ Requires-Dist: ty>=0.0.44 ; extra == 'dev'
29
28
  Requires-Dist: zstandard>=0.25.0 ; extra == 'dev'
30
29
  Requires-Dist: numpy>=1.26 ; extra == 'dev'
31
30
  Requires-Dist: orjson>=3.10 ; extra == 'dev'
31
+ Requires-Dist: openai>=2.43 ; extra == 'dev'
32
+ Requires-Dist: anthropic>=0.111 ; extra == 'dev'
32
33
  Requires-Dist: zstandard>=0.25.0 ; extra == 'mlx'
33
34
  Requires-Dist: numpy>=1.26 ; extra == 'mlx'
34
35
  Requires-Dist: orjson>=3.10 ; extra == 'mlx'
@@ -119,6 +120,17 @@ uv add "spawnllm[mlx]"
119
120
 
120
121
  `AdapterFuser.ensure_fused` fuses your compressed adapter into the base model once and caches the result in the Hugging Face hub layout; `MlxEngine` loads it on a dedicated worker thread, precomputes a prompt cache for your shared prefix messages, and batches generation. Wrap the engine in an `MlxBackend` and the same `run_sync` call works.
121
122
 
123
+ ### Call the same backends from Go or Rust
124
+
125
+ All three languages run the identical engine: argv planning, output parsing, schema strictification, and retry policy live once in a Rust core — linked natively by the Rust crate, embedded as WASM by the Go module and the Python package — pinned by a shared golden-vector suite and released in lockstep.
126
+
127
+ ```bash
128
+ go get github.com/yasyf/spawnllm/go # pure Go, no cgo — the core embeds as WASM
129
+ cargo add spawnllm # async-first, with a blocking mirror
130
+ ```
131
+
132
+ Both expose `Call`/`call` and typed `Extract`/`extract` against your existing CLI logins — see the [Go README](https://github.com/yasyf/spawnllm/tree/main/go) and the [Rust README](https://github.com/yasyf/spawnllm/tree/main/rust/spawnllm). MLX stays Python-only.
133
+
122
134
  ## More in the docs
123
135
 
124
136
  - **Spec-driven runs** — a literal model id, per-provider flag passthrough, and envelope-aware retry via `RunSpec` — [Running reference](https://yasyf.github.io/spawnllm/reference/#running)
@@ -71,6 +71,17 @@ uv add "spawnllm[mlx]"
71
71
 
72
72
  `AdapterFuser.ensure_fused` fuses your compressed adapter into the base model once and caches the result in the Hugging Face hub layout; `MlxEngine` loads it on a dedicated worker thread, precomputes a prompt cache for your shared prefix messages, and batches generation. Wrap the engine in an `MlxBackend` and the same `run_sync` call works.
73
73
 
74
+ ### Call the same backends from Go or Rust
75
+
76
+ All three languages run the identical engine: argv planning, output parsing, schema strictification, and retry policy live once in a Rust core — linked natively by the Rust crate, embedded as WASM by the Go module and the Python package — pinned by a shared golden-vector suite and released in lockstep.
77
+
78
+ ```bash
79
+ go get github.com/yasyf/spawnllm/go # pure Go, no cgo — the core embeds as WASM
80
+ cargo add spawnllm # async-first, with a blocking mirror
81
+ ```
82
+
83
+ Both expose `Call`/`call` and typed `Extract`/`extract` against your existing CLI logins — see the [Go README](https://github.com/yasyf/spawnllm/tree/main/go) and the [Rust README](https://github.com/yasyf/spawnllm/tree/main/rust/spawnllm). MLX stays Python-only.
84
+
74
85
  ## More in the docs
75
86
 
76
87
  - **Spec-driven runs** — a literal model id, per-provider flag passthrough, and envelope-aware retry via `RunSpec` — [Running reference](https://yasyf.github.io/spawnllm/reference/#running)
@@ -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.7.0"
4
+ version = "0.9.0"
5
5
  description = "Delete your subprocess wrappers around claude, codex, and gemini."
6
6
  readme = "README.md"
7
7
  license = "MIT"
@@ -22,11 +22,7 @@ dependencies = [
22
22
  "httpx>=0.27",
23
23
  "loguru>=0.7",
24
24
  "pydantic>=2",
25
- # Per-backend strict-schema transforms: openai.lib._pydantic.to_strict_json_schema
26
- # and anthropic.lib._parse._transform.transform_schema. Both import paths are
27
- # SDK-private, so the floors pin verified-working versions.
28
- "openai>=2.43",
29
- "anthropic>=0.111",
25
+ "wasmtime>=46",
30
26
  ]
31
27
 
32
28
  [project.optional-dependencies]
@@ -39,6 +35,12 @@ dev = [
39
35
  "zstandard>=0.25.0",
40
36
  "numpy>=1.26",
41
37
  "orjson>=3.10",
38
+ # The conformance SDK-crosscheck tests compare the core's strict-schema output
39
+ # against openai.lib._pydantic.to_strict_json_schema and
40
+ # anthropic.lib._parse._transform.transform_schema. Both import paths are
41
+ # SDK-private, so the floors pin verified-working versions.
42
+ "openai>=2.43",
43
+ "anthropic>=0.111",
42
44
  ]
43
45
  # Cross-platform LoRA adapter codec (zstd + numpy); imported without mlx.
44
46
  adapter = [
@@ -0,0 +1,166 @@
1
+ """Thin host binding to the embedded spawnllm-core wasm blob.
2
+
3
+ The Rust core owns all drift-prone logic behind `dispatch(op_json) -> result_json`;
4
+ this module loads the wasm32-wasip1 blob under wasmtime and marshals JSON in and out.
5
+ wasmtime-py exposes no in-memory WASI stderr sink, so a panicking core's diagnostics
6
+ are routed to a process-lifetime temp file whose tail is read back into the raised
7
+ `CoreTrap`. The core builds with panic=abort, so a trap leaves the allocator in an
8
+ untrustworthy state: the cached runtime is dropped and the next call re-instantiates.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import atexit
14
+ import json
15
+ import os
16
+ import tempfile
17
+ import threading
18
+ from dataclasses import dataclass
19
+ from importlib.resources import files
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from wasmtime import Engine, Func, Linker, Memory, Module, Store, Trap, WasiConfig, WasmtimeError
24
+
25
+ if TYPE_CHECKING:
26
+ from wasmtime._instance import InstanceExports
27
+
28
+ WASM_NAME = "spawnllm_core.wasm"
29
+ STDERR_TAIL_BYTES = 4096
30
+ MISSING_BLOB = (
31
+ f"spawnllm core wasm blob missing: {WASM_NAME} was not packaged. Build it with "
32
+ "`bash scripts/build_wasm.sh` (run `rustup target add wasm32-wasip1` first)."
33
+ )
34
+
35
+ __all__ = ["CoreError", "CoreTrap", "dispatch", "dispatch_raw", "version"]
36
+
37
+ LOCK = threading.Lock()
38
+ RUNTIME: Runtime | None = None
39
+
40
+
41
+ class CoreError(Exception):
42
+ def __init__(self, kind: str, msg: str) -> None:
43
+ super().__init__(f"{kind}: {msg}")
44
+ self.kind = kind
45
+ self.msg = msg
46
+
47
+
48
+ class CoreTrap(Exception):
49
+ pass
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class Runtime:
54
+ store: Store
55
+ alloc: Func
56
+ free: Func
57
+ call: Func
58
+ memory: Memory
59
+ stderr_path: Path
60
+
61
+ def invoke(self, request_json: str) -> str:
62
+ request = request_json.encode()
63
+ request_len = len(request)
64
+ request_ptr = self.alloc(self.store, request_len)
65
+ try:
66
+ self.memory.write(self.store, request, request_ptr)
67
+ packed = self.call(self.store, request_ptr, request_len)
68
+ except (Trap, WasmtimeError):
69
+ raise
70
+ except BaseException:
71
+ self.free(self.store, request_ptr, request_len)
72
+ raise
73
+ self.free(self.store, request_ptr, request_len)
74
+ response_ptr, response_len = packed >> 32, packed & 0xFFFFFFFF
75
+ try:
76
+ response = bytes(self.memory.read(self.store, response_ptr, response_ptr + response_len))
77
+ except (Trap, WasmtimeError):
78
+ raise
79
+ except BaseException:
80
+ self.free(self.store, response_ptr, response_len)
81
+ raise
82
+ self.free(self.store, response_ptr, response_len)
83
+ return response.decode()
84
+
85
+
86
+ def export[T](exports: InstanceExports, name: str, kind: type[T]) -> T:
87
+ value = exports[name]
88
+ assert isinstance(value, kind), f"core export {name} is {type(value).__name__}, not {kind.__name__}"
89
+ return value
90
+
91
+
92
+ def build() -> Runtime:
93
+ blob = files(__package__).joinpath(WASM_NAME)
94
+ if not blob.is_file():
95
+ raise FileNotFoundError(MISSING_BLOB)
96
+ engine = Engine()
97
+ linker = Linker(engine)
98
+ linker.define_wasi()
99
+ store = Store(engine)
100
+ stderr = Path((fd_name := tempfile.mkstemp(prefix="spawnllm-core-", suffix=".stderr"))[1])
101
+ os.close(fd_name[0])
102
+ wasi = WasiConfig()
103
+ wasi.stderr_file = str(stderr)
104
+ store.set_wasi(wasi)
105
+ exports = linker.instantiate(store, Module(engine, blob.read_bytes())).exports(store)
106
+ if (initialize := exports.get("_initialize")) is not None:
107
+ initialize(store)
108
+ return Runtime(
109
+ store=store,
110
+ alloc=export(exports, "sl_alloc", Func),
111
+ free=export(exports, "sl_free", Func),
112
+ call=export(exports, "sl_call", Func),
113
+ memory=export(exports, "memory", Memory),
114
+ stderr_path=stderr,
115
+ )
116
+
117
+
118
+ def load() -> Runtime:
119
+ global RUNTIME
120
+ if RUNTIME is None:
121
+ RUNTIME = build()
122
+ return RUNTIME
123
+
124
+
125
+ def reset() -> None:
126
+ global RUNTIME
127
+ if RUNTIME is not None:
128
+ RUNTIME.stderr_path.unlink(missing_ok=True)
129
+ RUNTIME = None
130
+
131
+
132
+ def fork_reset() -> None:
133
+ global LOCK, RUNTIME
134
+ LOCK = threading.Lock()
135
+ RUNTIME = None
136
+
137
+
138
+ atexit.register(reset)
139
+ os.register_at_fork(after_in_child=fork_reset)
140
+
141
+
142
+ def trap_message(runtime: Runtime, error: Exception) -> str:
143
+ tail = runtime.stderr_path.read_bytes()[-STDERR_TAIL_BYTES:].decode(errors="replace").strip()
144
+ return f"spawnllm core trap: {error}\nwasm stderr: {tail}" if tail else f"spawnllm core trap: {error}"
145
+
146
+
147
+ def dispatch_raw(request_json: str) -> str:
148
+ with LOCK:
149
+ runtime = load()
150
+ try:
151
+ return runtime.invoke(request_json)
152
+ except (Trap, WasmtimeError) as error:
153
+ message = trap_message(runtime, error)
154
+ reset()
155
+ raise CoreTrap(message) from error
156
+
157
+
158
+ def dispatch(op: str, input: dict | None = None) -> Any:
159
+ envelope = json.loads(dispatch_raw(json.dumps({"op": op} | ({"input": input} if input is not None else {}))))
160
+ if (error := envelope.get("err")) is not None:
161
+ raise CoreError(error["kind"], error["msg"])
162
+ return envelope["ok"]
163
+
164
+
165
+ def version() -> dict:
166
+ return dispatch("version")
@@ -1,18 +1,30 @@
1
- """Abstract execution contract for an LLM backend and its subprocess family."""
1
+ """Abstract execution contract for an LLM backend and its subprocess family.
2
+
3
+ Every drift-prone decision — argv planning, output resolution, schema
4
+ strictification, and auth-probe layout — executes in the embedded `spawnllm-core`
5
+ wasm engine via `spawnllm._core`. This module is the I/O host: it spawns
6
+ processes, mints and cleans temp files, seeds claude isolation, and runs the
7
+ auth probes the core lays out.
8
+ """
2
9
 
3
10
  from __future__ import annotations
4
11
 
12
+ import dataclasses
5
13
  import json
6
14
  import os
7
15
  import shutil
8
16
  import subprocess
17
+ import sys
18
+ import tempfile
9
19
  from abc import ABC, abstractmethod
10
20
  from dataclasses import dataclass
11
21
  from pathlib import Path
12
- from typing import TYPE_CHECKING, ClassVar
22
+ from typing import TYPE_CHECKING, Any, ClassVar
13
23
 
24
+ from spawnllm import _core
14
25
  from spawnllm.proc import acapture_cli, capture_cli
15
26
  from spawnllm.response import Error, Output, Response, Result
27
+ from spawnllm.spec import ClaudeConfig, CodexConfig, GeminiConfig
16
28
 
17
29
  if TYPE_CHECKING:
18
30
  from pydantic import BaseModel
@@ -93,19 +105,58 @@ class Invocation:
93
105
  cleanup_paths: tuple[str, ...] = ()
94
106
 
95
107
 
108
+ def run_probe(probe: dict[str, Any], *, timeout: int) -> bool:
109
+ """Execute one auth probe the core laid out, mirroring the reference host's probe kinds."""
110
+ match probe:
111
+ case {"kind": "exec_exit0", "argv": [*argv]}:
112
+ try:
113
+ return (
114
+ subprocess.run(
115
+ [*map(str, argv)], capture_output=True, text=True, timeout=timeout, check=False
116
+ ).returncode
117
+ == 0
118
+ )
119
+ except FileNotFoundError:
120
+ return False
121
+ case {"kind": "keychain_exists", "service": service, "account": account}:
122
+ try:
123
+ return (
124
+ subprocess.run(
125
+ ["security", "find-generic-password", "-s", str(service), "-a", str(account)],
126
+ capture_output=True,
127
+ timeout=timeout,
128
+ check=False,
129
+ ).returncode
130
+ == 0
131
+ )
132
+ except FileNotFoundError:
133
+ return False
134
+ case {"kind": "env_any", "vars": [*names]}:
135
+ return any(os.environ.get(str(name)) for name in names)
136
+ case {"kind": "file_exists", "path": path}:
137
+ return os.path.exists(str(path))
138
+ case _:
139
+ return False
140
+
141
+
96
142
  class LlmBackend(ABC):
97
143
  """Abstract execution contract for an LLM backend.
98
144
 
99
145
  Concrete backends map abstract model sizes to provider-specific model names
100
- and encapsulate how to execute a `RunSpec` and parse the raw response.
146
+ and encapsulate how to execute a `RunSpec` and parse the raw response. The
147
+ portable decisions — argv, output resolution, schema strictification — run in
148
+ the shared wasm core; a backend supplies only its `provider` and the I/O.
101
149
 
102
150
  Attributes:
103
151
  models: Mapping from abstract model size to the provider's model name.
104
152
  provider: Provider identifier keying a `RunSpec`'s `provider_configs`.
153
+ schema_dialect: Strict-schema dialect the core applies to a `response_model`
154
+ (`"anthropic"`, `"openai"`, or `None` to emit the plain JSON schema).
105
155
  """
106
156
 
107
157
  models: ClassVar[dict[TModel, str]]
108
158
  provider: ClassVar[ProviderName]
159
+ schema_dialect: ClassVar[str | None] = None
109
160
 
110
161
  @abstractmethod
111
162
  async def aexecute(self, spec: RunSpec) -> Response:
@@ -170,8 +221,9 @@ class LlmBackend(ABC):
170
221
  def schema_for(self, model: type[BaseModel]) -> str:
171
222
  """Serialize a Pydantic model into the JSON-schema string this backend expects.
172
223
 
173
- The default emits the model's plain JSON schema; provider backends
174
- override to apply their SDK's strict-schema transform.
224
+ The core's `strict_schema` op applies the backend's `schema_dialect`
225
+ transform over the model's plain JSON schema; a `None` dialect emits the
226
+ schema unchanged.
175
227
 
176
228
  Args:
177
229
  model: The Pydantic model describing the structured output.
@@ -179,37 +231,52 @@ class LlmBackend(ABC):
179
231
  Returns:
180
232
  A JSON-schema string suitable for this backend's structured-output argument.
181
233
  """
182
- return json.dumps(model.model_json_schema())
183
-
184
- def schema_arg(self, spec: RunSpec) -> str | None:
185
- """Return the JSON-schema string for `spec`, from a `response_model` or a raw `schema`.
186
-
187
- A `response_model` is run through `schema_for` (the provider's
188
- strict-schema transform); a raw `schema` passes verbatim — a dict is
189
- `json.dumps`'d, a string is returned unchanged. Returns `None` when
190
- neither is set.
191
-
192
- Args:
193
- spec: The configured run, carrying the optional `response_model` or `schema`.
234
+ schema = model.model_json_schema()
235
+ if self.schema_dialect is None:
236
+ return json.dumps(schema)
237
+ return json.dumps(_core.dispatch("strict_schema", {"dialect": self.schema_dialect, "schema": schema})["schema"])
194
238
 
195
- Returns:
196
- The JSON-schema string for this backend's structured-output argument, or `None`.
197
- """
239
+ def wire_schema(self, spec: RunSpec) -> dict[str, Any] | None:
240
+ """Return the portable schema object for `spec`, from a `response_model` or a raw `schema`."""
198
241
  if spec.response_model is not None:
199
- return self.schema_for(spec.response_model)
242
+ return json.loads(self.schema_for(spec.response_model))
200
243
  if spec.schema is not None:
201
- return json.dumps(spec.schema) if isinstance(spec.schema, dict) else spec.schema
244
+ return spec.schema if isinstance(spec.schema, dict) else json.loads(spec.schema)
245
+ return None
246
+
247
+ def openai_section(self) -> dict[str, Any] | None:
248
+ """Return the `openai_endpoint` wire section; `None` for every backend but the endpoint backend."""
202
249
  return None
203
250
 
251
+ def wire_spec(self, spec: RunSpec) -> dict[str, Any]:
252
+ """Serialize a `RunSpec` into the portable wire spec the core `plan`/`resolve` ops consume."""
253
+ return {
254
+ "prompt": spec.prompt,
255
+ "model": spec.model,
256
+ "schema": self.wire_schema(spec),
257
+ "agent": spec.agent,
258
+ "isolated": spec.isolated,
259
+ "timeout": spec.timeout,
260
+ "max_attempts": spec.max_attempts,
261
+ "claude": dataclasses.asdict(c) if (c := spec.config_for(ClaudeConfig)) is not None else None,
262
+ "codex": dataclasses.asdict(x) if (x := spec.config_for(CodexConfig)) is not None else None,
263
+ "gemini": dataclasses.asdict(g) if (g := spec.config_for(GeminiConfig)) is not None else None,
264
+ "openai_endpoint": self.openai_section(),
265
+ }
266
+
267
+ def core_plan(self, spec: RunSpec) -> dict[str, Any]:
268
+ """Ask the core to plan this run's invocation (an exec argv or an HTTP request)."""
269
+ return _core.dispatch(
270
+ "plan", {"host": {"platform": sys.platform}, "provider": self.provider, "spec": self.wire_spec(spec)}
271
+ )
272
+
204
273
  def to_response(self, raw: str, *, returncode: int, stderr: str, spec: RunSpec) -> Response:
205
- """Resolve a raw capture into a structured `Response`: detect failure, extract text, validate.
274
+ """Resolve a raw capture into a structured `Response` via the core `resolve` op.
206
275
 
207
- `output` always carries the full raw stream. A nonzero exit, an error
208
- envelope, or a `pydantic.ValidationError` from a non-conforming model all
209
- route through `error` (with the underlying exception preserved in
210
- `error.ex`) and leave `result` as `None`; a success yields `result` (text
211
- from `result_text`, plus the validated model from `result_value` when
212
- `spec.response_model` is set) and `error` as `None`.
276
+ `output` always carries the full raw stream. The core detects failure (a
277
+ nonzero exit or an error envelope) and extracts the final text and, when
278
+ `spec.response_model` is set, the structured value; a `pydantic.ValidationError`
279
+ from a non-conforming model routes through `error`.
213
280
 
214
281
  Args:
215
282
  raw: The raw output read wherever the provider wrote it.
@@ -223,37 +290,32 @@ class LlmBackend(ABC):
223
290
  import pydantic
224
291
 
225
292
  output = Output(raw)
226
- if returncode != 0:
227
- msg = f"{self.provider} exited {returncode}: {stderr.strip()[-2000:]}"
228
- return Response(spec=spec, output=output, error=Error(msg, BackendCallError(msg)))
229
- if (err := self.envelope_error(raw)) is not None:
230
- return Response(spec=spec, output=output, error=Error(err, BackendCallError(err)))
293
+ resolved = _core.dispatch(
294
+ "resolve",
295
+ {
296
+ "provider": self.provider,
297
+ "raw": raw,
298
+ "returncode": returncode,
299
+ "stderr": stderr,
300
+ "wants_value": spec.response_model is not None,
301
+ },
302
+ )
303
+ if resolved["status"] != "ok":
304
+ return Response(spec=spec, output=output, error=Error(resolved["msg"], BackendCallError(resolved["msg"])))
231
305
  if spec.response_model is None:
232
- return Response(spec=spec, output=output, result=Result(raw=self.result_text(raw)))
306
+ return Response(spec=spec, output=output, result=Result(raw=resolved["text"]))
233
307
  try:
234
- parsed = spec.response_model.model_validate(self.result_value(raw))
308
+ parsed = spec.response_model.model_validate(resolved["value"])
235
309
  except pydantic.ValidationError as e:
236
310
  return Response(spec=spec, output=output, error=Error(str(e), e))
237
- return Response(spec=spec, output=output, result=Result(raw=self.result_text(raw), parsed=parsed))
238
-
239
- def result_text(self, raw: str) -> str:
240
- """Return the final text output from a raw capture; the default is `raw` unchanged."""
241
- return raw
242
-
243
- def result_value(self, raw: str) -> object:
244
- """Return the JSON value to validate from a raw capture; the default parses `raw` as JSON."""
245
- return json.loads(raw)
246
-
247
- def envelope_error(self, raw: str) -> str | None:
248
- """Return the provider's error message from an error envelope, or `None` on success."""
249
- return None
311
+ return Response(spec=spec, output=output, result=Result(raw=resolved["text"], parsed=parsed))
250
312
 
251
313
  def accounting(self, raw: str) -> tuple[float | None, dict[str, object] | None]:
252
314
  """Return the `(cost_usd, usage)` an attempt's output reports, or `(None, None)` when it carries neither.
253
315
 
254
316
  The retry loop calls this on each transient failure it discards, so a
255
317
  caller reconciling spend can still see the cost. The default parses
256
- nothing; a backend whose envelope records spend overrides it.
318
+ nothing; the CLI family reads the core's `resolve` accounting.
257
319
 
258
320
  Args:
259
321
  raw: The raw output read wherever the provider wrote it.
@@ -267,9 +329,9 @@ class LlmBackend(ABC):
267
329
  class CliBackend(LlmBackend):
268
330
  """Execution contract for the subprocess-backed LLM family.
269
331
 
270
- Concrete CLI backends build an argv from a `RunSpec`; `aexecute`/`execute`
271
- run it, merge environment overrides, and resolve the result from stdout or a
272
- designated result file.
332
+ The core plans each invocation's argv, files, env, and result source; this
333
+ class materializes that plan into temp files and a subprocess, runs it, and
334
+ resolves the result. Concrete CLI backends supply only ClassVars.
273
335
 
274
336
  Attributes:
275
337
  binary: Name of the backend's CLI executable on PATH.
@@ -279,31 +341,92 @@ class CliBackend(LlmBackend):
279
341
  binary: ClassVar[str]
280
342
  install_hint: ClassVar[str]
281
343
 
282
- @abstractmethod
344
+ def claude_isolation(self) -> str:
345
+ """Return the isolated config home a claude run substitutes into `${isolated_config_dir}`."""
346
+ raise NotImplementedError
347
+
283
348
  def build_command(self, spec: RunSpec) -> list[str]:
284
- """Build the CLI argv for a single invocation.
349
+ """Return the core-planned argv (with `${file:id}` placeholders) for a single invocation."""
350
+ return self.core_plan(spec)["argv"]
351
+
352
+ def invocation(self, spec: RunSpec) -> Invocation:
353
+ """Materialize the core's exec plan into a runnable `Invocation`.
354
+
355
+ Mints a temp file per `files[]` entry (writing its content when non-null),
356
+ substitutes `${file:id}` placeholders in the argv and — for a claude run —
357
+ `${isolated_config_dir}`, and wires the plan's stdout/result source and the
358
+ minted paths into the returned `Invocation` for cleanup.
285
359
 
286
360
  Args:
287
- spec: The configured run to translate into argv.
361
+ spec: The configured run to translate into an invocation.
288
362
 
289
363
  Returns:
290
- The argv list to execute.
364
+ An `Invocation` carrying the materialized argv, stdin, and result source.
291
365
  """
366
+ plan = self.core_plan(spec)
367
+ paths: dict[str, str] = {}
368
+ try:
369
+ for entry in plan["files"]:
370
+ fd, path = tempfile.mkstemp(suffix=entry["suffix"])
371
+ paths[entry["id"]] = path
372
+ try:
373
+ if entry["content"] is not None:
374
+ os.write(fd, entry["content"].encode())
375
+ finally:
376
+ os.close(fd)
377
+ tokens = {f"${{file:{file_id}}}": path for file_id, path in paths.items()}
378
+ argv = [tokens.get(arg, arg) for arg in self.build_command(spec)]
379
+ if plan["needs_claude_isolation"]:
380
+ directory = self.claude_isolation()
381
+ argv = [arg.replace("${isolated_config_dir}", directory) for arg in argv]
382
+ except BaseException:
383
+ for path in paths.values():
384
+ Path(path).unlink(missing_ok=True)
385
+ raise
386
+ return Invocation(
387
+ argv,
388
+ plan["stdin"],
389
+ result_path=paths.get("result") if plan["read_result_from"] == "file:result" else None,
390
+ stdout_path=paths.get("stdout") if plan["stdout_to_file"] else None,
391
+ cleanup_paths=tuple(paths.values()),
392
+ )
292
393
 
293
- def invocation(self, spec: RunSpec) -> Invocation:
294
- """Build the argv, stdin, and result source for a single invocation.
394
+ def env(self, spec: RunSpec) -> dict[str, str]:
395
+ """Return the core-planned env, substituting the isolated config home into a claude run's values.
396
+
397
+ Args:
398
+ spec: The configured run; the plan gates isolation on `spec.isolated`.
399
+
400
+ Returns:
401
+ The plan's env map with `${isolated_config_dir}` resolved, or `{}`.
402
+ """
403
+ plan = self.core_plan(spec)
404
+ if not plan["needs_claude_isolation"]:
405
+ return plan["env"]
406
+ directory = self.claude_isolation()
407
+ return {key: value.replace("${isolated_config_dir}", directory) for key, value in plan["env"].items()}
295
408
 
296
- The default delivers the prompt over stdin and reads the result from
297
- stdout; subclasses override to deliver the prompt inline or to read the
298
- result from a file.
409
+ def accounting(self, raw: str) -> tuple[float | None, dict[str, object] | None]:
410
+ """Return the `(cost_usd, usage)` the core's `resolve` op reads from `raw`."""
411
+ resolved = _core.dispatch(
412
+ "resolve", {"provider": self.provider, "raw": raw, "returncode": 0, "stderr": "", "wants_value": False}
413
+ )
414
+ return resolved["cost_usd"], resolved["usage"]
415
+
416
+ def is_authenticated(self, *, timeout: int) -> bool:
417
+ """Report whether any of the core's auth probes for this provider succeeds.
299
418
 
300
419
  Args:
301
- spec: The configured run to translate into an invocation.
420
+ timeout: Seconds to wait for each subprocess-backed probe.
302
421
 
303
422
  Returns:
304
- An `Invocation` carrying the argv, stdin text, and result source.
423
+ `True` when a probe reports an authenticated session.
305
424
  """
306
- return Invocation(self.build_command(spec), spec.prompt)
425
+ probes = _core.dispatch(
426
+ "auth_probes",
427
+ {"provider": self.provider, "host": {"platform": sys.platform, "home": str(Path.home())}},
428
+ )
429
+ return any(run_probe(probe, timeout=timeout) for probe in probes["probes"])
307
430
 
308
431
  def timed_out(self, spec: RunSpec) -> Response:
309
432
  msg = f"{self.provider} timed out after {spec.timeout}s"