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.
- {spawnllm-0.7.0 → spawnllm-0.9.0}/PKG-INFO +15 -3
- {spawnllm-0.7.0 → spawnllm-0.9.0}/README.md +11 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/pyproject.toml +8 -6
- spawnllm-0.9.0/spawnllm/_core/__init__.py +166 -0
- spawnllm-0.9.0/spawnllm/_core/spawnllm_core.wasm +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/backends/base.py +188 -65
- spawnllm-0.9.0/spawnllm/backends/claude.py +102 -0
- spawnllm-0.9.0/spawnllm/backends/codex.py +39 -0
- spawnllm-0.9.0/spawnllm/backends/gemini.py +63 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/backends/mlx.py +16 -2
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/backends/openai_endpoint.py +13 -61
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/cli.py +2 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/run.py +27 -17
- spawnllm-0.9.0/spawnllm/structured.py +48 -0
- spawnllm-0.7.0/spawnllm/backends/claude.py +0 -276
- spawnllm-0.7.0/spawnllm/backends/codex.py +0 -172
- spawnllm-0.7.0/spawnllm/backends/gemini.py +0 -230
- spawnllm-0.7.0/spawnllm/structured.py +0 -116
- {spawnllm-0.7.0 → spawnllm-0.9.0}/LICENSE +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/__init__.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/__main__.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/backends/__init__.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/backends/registry.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/call.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/extract.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/mlx/engine.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/proc.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/py.typed +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/response.py +0 -0
- {spawnllm-0.7.0 → spawnllm-0.9.0}/spawnllm/spec.py +0 -0
- {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.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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")
|
|
Binary file
|
|
@@ -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
|
|
174
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"""
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
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
|
|
274
|
+
"""Resolve a raw capture into a structured `Response` via the core `resolve` op.
|
|
206
275
|
|
|
207
|
-
`output` always carries the full raw stream.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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=
|
|
306
|
+
return Response(spec=spec, output=output, result=Result(raw=resolved["text"]))
|
|
233
307
|
try:
|
|
234
|
-
parsed = spec.response_model.model_validate(
|
|
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=
|
|
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;
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
361
|
+
spec: The configured run to translate into an invocation.
|
|
288
362
|
|
|
289
363
|
Returns:
|
|
290
|
-
|
|
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
|
|
294
|
-
"""
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
420
|
+
timeout: Seconds to wait for each subprocess-backed probe.
|
|
302
421
|
|
|
303
422
|
Returns:
|
|
304
|
-
|
|
423
|
+
`True` when a probe reports an authenticated session.
|
|
305
424
|
"""
|
|
306
|
-
|
|
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"
|