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.
Files changed (35) hide show
  1. {spawnllm-0.17.0 → spawnllm-0.17.2}/PKG-INFO +1 -1
  2. {spawnllm-0.17.0 → spawnllm-0.17.2}/pyproject.toml +1 -1
  3. {spawnllm-0.17.0 → spawnllm-0.17.2}/pyproject.toml.orig +1 -1
  4. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/_core/__init__.py +7 -2
  5. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/_core/spawnllm_core.wasm +0 -0
  6. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/base.py +10 -4
  7. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/claude.py +55 -11
  8. {spawnllm-0.17.0 → spawnllm-0.17.2}/LICENSE +0 -0
  9. {spawnllm-0.17.0 → spawnllm-0.17.2}/README.md +0 -0
  10. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/__init__.py +0 -0
  11. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/__main__.py +0 -0
  12. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/__init__.py +0 -0
  13. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/apple.py +0 -0
  14. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/claude_sdk.py +0 -0
  15. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/codex.py +0 -0
  16. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/gemini.py +0 -0
  17. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/mlx.py +0 -0
  18. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/openai_endpoint.py +0 -0
  19. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/backends/registry.py +0 -0
  20. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/call.py +0 -0
  21. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/cli.py +0 -0
  22. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/decide.py +0 -0
  23. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/extract.py +0 -0
  24. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/mlx/__init__.py +0 -0
  25. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/mlx/codec.py +0 -0
  26. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/mlx/engine.py +0 -0
  27. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/mlx/fuse.py +0 -0
  28. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/mlx/patches.py +0 -0
  29. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/proc.py +0 -0
  30. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/py.typed +0 -0
  31. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/response.py +0 -0
  32. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/run.py +0 -0
  33. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/spec.py +0 -0
  34. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/structured.py +0 -0
  35. {spawnllm-0.17.0 → spawnllm-0.17.2}/spawnllm/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spawnllm
3
- Version: 0.17.0
3
+ Version: 0.17.2
4
4
  Summary: Delete your subprocess wrappers around claude, codex, and gemini.
5
5
  Keywords:
6
6
  Author: Yasyf Mohamedali
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spawnllm"
3
- version = "0.17.0"
3
+ version = "0.17.2"
4
4
  description = "Delete your subprocess wrappers around claude, codex, and gemini."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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.0"
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:
@@ -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
- inv = self.invocation(spec)
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
- | self.env(spec)
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
- inv = self.invocation(spec)
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
- | self.env(spec)
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 (falling back to
75
- the Keychain when the credentials file is absent) and hands them to
76
- `claude_isolation_seed` for the exact files-and-modes to write and the env
77
- to set. The files land in a fresh temp dir removed at interpreter exit,
78
- created on the first call and cached on the backend; the env is resolved
79
- on every call so a renewed Keychain token reaches the next run, and it
80
- only ever lives in memory. An `api_auth` run names no source at all, so it
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 = read_file_opt(sources["credentials_path"]) if sources["credentials_path"] else None
100
- if credentials_json is None and sources["keychain_service"] is not None:
101
- credentials_json = keychain_credentials(sources["keychain_service"])
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", {"account_json": account_json, "credentials_json": credentials_json}
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