spawnllm 0.17.0__tar.gz → 0.17.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.17.0 → spawnllm-0.17.2}/PKG-INFO +1 -1
- {spawnllm-0.17.0 → spawnllm-0.17.2}/pyproject.toml +1 -1
- {spawnllm-0.17.0 → spawnllm-0.17.2}/pyproject.toml.orig +1 -1
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/_core/__init__.py +7 -2
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/_core/spawnllm_core.wasm +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/base.py +10 -4
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/claude.py +55 -11
- {spawnllm-0.17.0 → spawnllm-0.17.2}/LICENSE +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/README.md +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/__init__.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/__main__.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/__init__.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/apple.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/claude_sdk.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/codex.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/gemini.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/mlx.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/openai_endpoint.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/registry.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/call.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/cli.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/decide.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/extract.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/mlx/engine.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/proc.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/py.typed +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/response.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/run.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/spec.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/structured.py +0 -0
- {spawnllm-0.17.0 → spawnllm-0.17.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.17.
|
|
4
|
+
version = "0.17.2"
|
|
5
5
|
description = "Delete your subprocess wrappers around claude, codex, and gemini."
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "MIT"
|
|
@@ -20,9 +20,8 @@ from importlib.resources import files
|
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from typing import TYPE_CHECKING, Any
|
|
22
22
|
|
|
23
|
-
from wasmtime import Engine, Func, Linker, Memory, Module, Store, Trap, WasiConfig, WasmtimeError
|
|
24
|
-
|
|
25
23
|
if TYPE_CHECKING:
|
|
24
|
+
from wasmtime import Func, Memory, Store
|
|
26
25
|
from wasmtime._instance import InstanceExports
|
|
27
26
|
|
|
28
27
|
WASM_NAME = "spawnllm_core.wasm"
|
|
@@ -59,6 +58,8 @@ class Runtime:
|
|
|
59
58
|
stderr_path: Path
|
|
60
59
|
|
|
61
60
|
def invoke(self, request_json: str) -> str:
|
|
61
|
+
from wasmtime import Trap, WasmtimeError
|
|
62
|
+
|
|
62
63
|
request = request_json.encode()
|
|
63
64
|
request_len = len(request)
|
|
64
65
|
request_ptr = self.alloc(self.store, request_len)
|
|
@@ -90,6 +91,8 @@ def export[T](exports: InstanceExports, name: str, kind: type[T]) -> T:
|
|
|
90
91
|
|
|
91
92
|
|
|
92
93
|
def build() -> Runtime:
|
|
94
|
+
from wasmtime import Engine, Func, Linker, Memory, Module, Store, WasiConfig
|
|
95
|
+
|
|
93
96
|
blob = files(__package__).joinpath(WASM_NAME)
|
|
94
97
|
if not blob.is_file():
|
|
95
98
|
raise FileNotFoundError(MISSING_BLOB)
|
|
@@ -145,6 +148,8 @@ def trap_message(runtime: Runtime, error: Exception) -> str:
|
|
|
145
148
|
|
|
146
149
|
|
|
147
150
|
def dispatch_raw(request_json: str) -> str:
|
|
151
|
+
from wasmtime import Trap, WasmtimeError
|
|
152
|
+
|
|
148
153
|
with LOCK:
|
|
149
154
|
runtime = load()
|
|
150
155
|
try:
|
|
Binary file
|
|
@@ -500,14 +500,17 @@ class CliBackend(LlmBackend):
|
|
|
500
500
|
return Response(spec=spec, output=Output(""), error=Error(msg, TimeoutError(msg)))
|
|
501
501
|
|
|
502
502
|
async def aexecute(self, spec: RunSpec) -> Response:
|
|
503
|
-
|
|
503
|
+
return await self.aexecute_invocation(spec, self.invocation(spec), self.env(spec))
|
|
504
|
+
|
|
505
|
+
async def aexecute_invocation(self, spec: RunSpec, inv: Invocation, env: dict[str, str]) -> Response:
|
|
506
|
+
"""Run a materialized `inv` asynchronously under `env` and resolve it, removing its temp files."""
|
|
504
507
|
try:
|
|
505
508
|
try:
|
|
506
509
|
rr = await acapture_cli(
|
|
507
510
|
inv.argv,
|
|
508
511
|
input=inv.stdin,
|
|
509
512
|
env={key: value for key, value in os.environ.items() if key not in inv.env_unset}
|
|
510
|
-
|
|
|
513
|
+
| env
|
|
511
514
|
| (spec.env or {}),
|
|
512
515
|
cwd=spec.cwd,
|
|
513
516
|
timeout=spec.timeout,
|
|
@@ -522,14 +525,17 @@ class CliBackend(LlmBackend):
|
|
|
522
525
|
return self.to_response(raw, returncode=rr.returncode, stderr=rr.stderr, spec=spec)
|
|
523
526
|
|
|
524
527
|
def execute(self, spec: RunSpec) -> Response:
|
|
525
|
-
|
|
528
|
+
return self.execute_invocation(spec, self.invocation(spec), self.env(spec))
|
|
529
|
+
|
|
530
|
+
def execute_invocation(self, spec: RunSpec, inv: Invocation, env: dict[str, str]) -> Response:
|
|
531
|
+
"""Run a materialized `inv` under `env` and resolve it, removing its temp files."""
|
|
526
532
|
try:
|
|
527
533
|
try:
|
|
528
534
|
rr = capture_cli(
|
|
529
535
|
inv.argv,
|
|
530
536
|
input=inv.stdin,
|
|
531
537
|
env={key: value for key, value in os.environ.items() if key not in inv.env_unset}
|
|
532
|
-
|
|
|
538
|
+
| env
|
|
533
539
|
| (spec.env or {}),
|
|
534
540
|
cwd=spec.cwd,
|
|
535
541
|
timeout=spec.timeout,
|
|
@@ -15,6 +15,8 @@ from spawnllm import _core
|
|
|
15
15
|
from spawnllm.backends.base import ClaudeIsolation, CliBackend
|
|
16
16
|
|
|
17
17
|
if TYPE_CHECKING:
|
|
18
|
+
from spawnllm.response import Response
|
|
19
|
+
from spawnllm.spec import RunSpec
|
|
18
20
|
from spawnllm.types import ProviderName, TModel
|
|
19
21
|
|
|
20
22
|
CLAUDE_MODELS: dict[TModel, str] = {"small": "haiku", "medium": "sonnet", "large": "opus"}
|
|
@@ -64,6 +66,7 @@ class ClaudeCliBackend(CliBackend):
|
|
|
64
66
|
|
|
65
67
|
_isolated_config_dir: str | None = None
|
|
66
68
|
_api_config_dir: str | None = None
|
|
69
|
+
_rejected_tokens: frozenset[str] = frozenset()
|
|
67
70
|
|
|
68
71
|
def claude_isolation(self, api_auth: bool) -> ClaudeIsolation:
|
|
69
72
|
"""Return the isolation for one run: the process-lifetime config home and the env resolved now.
|
|
@@ -71,13 +74,14 @@ class ClaudeCliBackend(CliBackend):
|
|
|
71
74
|
The core's `claude_isolation_sources` op resolves the account pointer,
|
|
72
75
|
credentials file, and Keychain service from the caller's effective config
|
|
73
76
|
home, naming no credential source when the process already carries
|
|
74
|
-
`CLAUDE_CODE_OAUTH_TOKEN`; this host reads those sources (
|
|
75
|
-
the
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
`CLAUDE_CODE_OAUTH_TOKEN`; this host reads those sources (the Keychain
|
|
78
|
+
before the credentials file, the order Claude Code itself reads them) and
|
|
79
|
+
hands them, with every token a run saw rejected, to `claude_isolation_seed`
|
|
80
|
+
for the exact files-and-modes to write and the env to set. The files land
|
|
81
|
+
in a fresh temp dir removed at interpreter exit, created on the first
|
|
82
|
+
call and cached on the backend; the env is resolved on every call so a
|
|
83
|
+
renewed Keychain token reaches the next run, and it only ever lives in
|
|
84
|
+
memory. An `api_auth` run names no source at all, so it
|
|
81
85
|
reads no account, credentials file, or Keychain item and gets an empty home
|
|
82
86
|
of its own.
|
|
83
87
|
"""
|
|
@@ -96,11 +100,21 @@ class ClaudeCliBackend(CliBackend):
|
|
|
96
100
|
},
|
|
97
101
|
)
|
|
98
102
|
account_json = read_file_opt(sources["account_path"]) if sources["account_path"] else None
|
|
99
|
-
credentials_json =
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
credentials_json = [
|
|
104
|
+
credentials
|
|
105
|
+
for credentials in (
|
|
106
|
+
keychain_credentials(sources["keychain_service"]) if sources["keychain_service"] else None,
|
|
107
|
+
read_file_opt(sources["credentials_path"]) if sources["credentials_path"] else None,
|
|
108
|
+
)
|
|
109
|
+
if credentials is not None
|
|
110
|
+
]
|
|
102
111
|
seed = _core.dispatch(
|
|
103
|
-
"claude_isolation_seed",
|
|
112
|
+
"claude_isolation_seed",
|
|
113
|
+
{
|
|
114
|
+
"account_json": account_json,
|
|
115
|
+
"credentials_json": credentials_json,
|
|
116
|
+
"rejected_tokens": sorted(self._rejected_tokens),
|
|
117
|
+
},
|
|
104
118
|
)
|
|
105
119
|
cached = self._api_config_dir if api_auth else self._isolated_config_dir
|
|
106
120
|
if cached is None:
|
|
@@ -116,3 +130,33 @@ class ClaudeCliBackend(CliBackend):
|
|
|
116
130
|
else:
|
|
117
131
|
self._isolated_config_dir = cached
|
|
118
132
|
return ClaudeIsolation(cached, seed["env"])
|
|
133
|
+
|
|
134
|
+
def reject_credentials(self, spec: RunSpec, env: dict[str, str], response: Response) -> bool:
|
|
135
|
+
"""Record the OAuth token in a run's `env` as rejected when the core reads `response` as an auth failure.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
`True` when another credential source remains to retry the run with.
|
|
139
|
+
"""
|
|
140
|
+
token = env.get("CLAUDE_CODE_OAUTH_TOKEN")
|
|
141
|
+
if token is None or response.error is None:
|
|
142
|
+
return False
|
|
143
|
+
if not _core.dispatch("claude_auth_rejected", {"error_msg": response.error.msg})["rejected"]:
|
|
144
|
+
return False
|
|
145
|
+
self._rejected_tokens |= {token}
|
|
146
|
+
return self.claude_isolation(spec.api_auth).env.get("CLAUDE_CODE_OAUTH_TOKEN") not in self._rejected_tokens
|
|
147
|
+
|
|
148
|
+
async def aexecute(self, spec: RunSpec) -> Response:
|
|
149
|
+
env = self.env(spec)
|
|
150
|
+
response = await self.aexecute_invocation(spec, self.invocation(spec), env)
|
|
151
|
+
while self.reject_credentials(spec, env, response):
|
|
152
|
+
env = self.env(spec)
|
|
153
|
+
response = await self.aexecute_invocation(spec, self.invocation(spec), env)
|
|
154
|
+
return response
|
|
155
|
+
|
|
156
|
+
def execute(self, spec: RunSpec) -> Response:
|
|
157
|
+
env = self.env(spec)
|
|
158
|
+
response = self.execute_invocation(spec, self.invocation(spec), env)
|
|
159
|
+
while self.reject_credentials(spec, env, response):
|
|
160
|
+
env = self.env(spec)
|
|
161
|
+
response = self.execute_invocation(spec, self.invocation(spec), env)
|
|
162
|
+
return response
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|