spawnllm 0.2.0__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spawnllm
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools.
5
5
  Keywords:
6
6
  Author: Yasyf Mohamedali
@@ -16,6 +16,8 @@ Classifier: Typing :: Typed
16
16
  Requires-Dist: click>=8
17
17
  Requires-Dist: loguru>=0.7
18
18
  Requires-Dist: pydantic>=2
19
+ Requires-Dist: openai>=2.43
20
+ Requires-Dist: anthropic>=0.111
19
21
  Requires-Dist: zstandard>=0.25.0 ; extra == 'adapter'
20
22
  Requires-Dist: numpy>=1.26 ; extra == 'adapter'
21
23
  Requires-Dist: orjson>=3.10 ; extra == 'adapter'
@@ -83,18 +85,70 @@ uv add "spawnllm[mlx]"
83
85
 
84
86
  ## Quickstart
85
87
 
86
- List the backends spawnllm can drive:
88
+ See which backends are installed and authenticated, and which one auto-selection picks:
87
89
 
88
90
  ```bash
89
- uvx spawnllm backends
91
+ uvx spawnllm status
90
92
  ```
91
93
 
92
94
  ```
93
- claude
94
- codex
95
- mlx
95
+ claude: ready
96
+ codex: ready
97
+ selected: claude
96
98
  ```
97
99
 
100
+ Make a request by passing a prompt as the argument, or piping it over stdin:
101
+
102
+ ```bash
103
+ uvx spawnllm call --backend claude "What is 2+2? Reply with just the number."
104
+ ```
105
+
106
+ ```
107
+ 4
108
+ ```
109
+
110
+ `--model small|medium|large` swaps the tier, which each backend maps to a concrete model.
111
+ The `claude` backend resolves `small` to Haiku, `medium` to Sonnet, and `large` to Opus. Add
112
+ `--agent` to let the call use tools.
113
+
114
+ ### From Python
115
+
116
+ `call` runs one request and returns the response. With no `backend`, it auto-selects the
117
+ first installed, authenticated CLI:
118
+
119
+ ```python
120
+ from spawnllm import call
121
+
122
+ print(call("Reply with just the word: pong"))
123
+ # pong
124
+ ```
125
+
126
+ Pin a backend and tier explicitly, or pass a Pydantic model to get a validated object back
127
+ instead of text:
128
+
129
+ ```python
130
+ from pydantic import BaseModel
131
+
132
+ from spawnllm import call, ClaudeCliBackend
133
+
134
+
135
+ class Capital(BaseModel):
136
+ country: str
137
+ capital: str
138
+
139
+
140
+ result = call(
141
+ "What is the capital of France?",
142
+ backend=ClaudeCliBackend(),
143
+ model="large",
144
+ response_model=Capital,
145
+ )
146
+ print(result.capital) # Paris
147
+ ```
148
+
149
+ When you don't pin a backend, set `specialty=` to scope auto-selection by task. The
150
+ `debugging` and `review` specialties route to Codex, and `general` routes to Claude.
151
+
98
152
  ## What problems does this solve?
99
153
 
100
154
  Every tool that shells out to `claude` or `codex` rebuilds the same plumbing: argv
@@ -38,18 +38,70 @@ uv add "spawnllm[mlx]"
38
38
 
39
39
  ## Quickstart
40
40
 
41
- List the backends spawnllm can drive:
41
+ See which backends are installed and authenticated, and which one auto-selection picks:
42
42
 
43
43
  ```bash
44
- uvx spawnllm backends
44
+ uvx spawnllm status
45
45
  ```
46
46
 
47
47
  ```
48
- claude
49
- codex
50
- mlx
48
+ claude: ready
49
+ codex: ready
50
+ selected: claude
51
51
  ```
52
52
 
53
+ Make a request by passing a prompt as the argument, or piping it over stdin:
54
+
55
+ ```bash
56
+ uvx spawnllm call --backend claude "What is 2+2? Reply with just the number."
57
+ ```
58
+
59
+ ```
60
+ 4
61
+ ```
62
+
63
+ `--model small|medium|large` swaps the tier, which each backend maps to a concrete model.
64
+ The `claude` backend resolves `small` to Haiku, `medium` to Sonnet, and `large` to Opus. Add
65
+ `--agent` to let the call use tools.
66
+
67
+ ### From Python
68
+
69
+ `call` runs one request and returns the response. With no `backend`, it auto-selects the
70
+ first installed, authenticated CLI:
71
+
72
+ ```python
73
+ from spawnllm import call
74
+
75
+ print(call("Reply with just the word: pong"))
76
+ # pong
77
+ ```
78
+
79
+ Pin a backend and tier explicitly, or pass a Pydantic model to get a validated object back
80
+ instead of text:
81
+
82
+ ```python
83
+ from pydantic import BaseModel
84
+
85
+ from spawnllm import call, ClaudeCliBackend
86
+
87
+
88
+ class Capital(BaseModel):
89
+ country: str
90
+ capital: str
91
+
92
+
93
+ result = call(
94
+ "What is the capital of France?",
95
+ backend=ClaudeCliBackend(),
96
+ model="large",
97
+ response_model=Capital,
98
+ )
99
+ print(result.capital) # Paris
100
+ ```
101
+
102
+ When you don't pin a backend, set `specialty=` to scope auto-selection by task. The
103
+ `debugging` and `review` specialties route to Codex, and `general` routes to Claude.
104
+
53
105
  ## What problems does this solve?
54
106
 
55
107
  Every tool that shells out to `claude` or `codex` rebuilds the same plumbing: argv
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spawnllm"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -20,6 +20,11 @@ dependencies = [
20
20
  "click>=8",
21
21
  "loguru>=0.7",
22
22
  "pydantic>=2",
23
+ # Per-backend strict-schema transforms: openai.lib._pydantic.to_strict_json_schema
24
+ # and anthropic.lib._parse._transform.transform_schema. Both import paths are
25
+ # SDK-private, so the floors pin verified-working versions.
26
+ "openai>=2.43",
27
+ "anthropic>=0.111",
23
28
  ]
24
29
 
25
30
  [project.optional-dependencies]
@@ -17,6 +17,7 @@ from spawnllm.backends import (
17
17
  ClaudeCliBackend,
18
18
  CodexCliBackend,
19
19
  GeminiCliBackend,
20
+ Invocation,
20
21
  LlmBackend,
21
22
  LlmBackends,
22
23
  select_backend,
@@ -28,7 +29,6 @@ from spawnllm.structured import (
28
29
  parse_result_envelope,
29
30
  parse_structured_output,
30
31
  resolve_schema_path,
31
- schema_for,
32
32
  )
33
33
  from spawnllm.types import TModel, TSpecialty
34
34
 
@@ -42,6 +42,7 @@ __all__ = [
42
42
  "ClaudeCliBackend",
43
43
  "CodexCliBackend",
44
44
  "GeminiCliBackend",
45
+ "Invocation",
45
46
  "LlmBackend",
46
47
  "LlmBackends",
47
48
  "TModel",
@@ -55,6 +56,5 @@ __all__ = [
55
56
  "parse_structured_output",
56
57
  "resolve_schema_path",
57
58
  "run_cli",
58
- "schema_for",
59
59
  "select_backend",
60
60
  ]
@@ -8,6 +8,7 @@ from spawnllm.backends.base import (
8
8
  BackendReady,
9
9
  BackendStatus,
10
10
  BackendUnavailable,
11
+ Invocation,
11
12
  LlmBackend,
12
13
  )
13
14
  from spawnllm.backends.claude import ClaudeCliBackend
@@ -25,6 +26,7 @@ __all__ = [
25
26
  "ClaudeCliBackend",
26
27
  "CodexCliBackend",
27
28
  "GeminiCliBackend",
29
+ "Invocation",
28
30
  "LlmBackend",
29
31
  "LlmBackends",
30
32
  "select_backend",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import shutil
6
7
  from abc import ABC, abstractmethod
7
8
  from dataclasses import dataclass
@@ -56,6 +57,24 @@ class BackendUnavailable(RuntimeError):
56
57
  """Raised when no backend is ready (installed and authenticated)."""
57
58
 
58
59
 
60
+ @dataclass(frozen=True)
61
+ class Invocation:
62
+ """A built CLI invocation: argv, optional stdin, and where to read the result.
63
+
64
+ Attributes:
65
+ argv: The argv list to execute.
66
+ stdin: Prompt text delivered over stdin, or `None` when delivered inline.
67
+ result_path: File the backend writes its final message to; when set, the
68
+ result is read from this file instead of stdout.
69
+ cleanup_paths: Temp files to remove once the invocation completes.
70
+ """
71
+
72
+ argv: list[str]
73
+ stdin: str | None = None
74
+ result_path: str | None = None
75
+ cleanup_paths: tuple[str, ...] = ()
76
+
77
+
59
78
  class LlmBackend(ABC):
60
79
  """Abstract interface for an LLM CLI backend.
61
80
 
@@ -132,13 +151,26 @@ class LlmBackend(ABC):
132
151
  `True` when the CLI reports an authenticated session.
133
152
  """
134
153
 
135
- def invocation(
136
- self, prompt: str, *, model: str, schema_path: str | None, agent: bool
137
- ) -> tuple[list[str], str | None]:
138
- """Build the argv and stdin text for a single invocation.
154
+ def schema_for(self, model: type[BaseModel]) -> str:
155
+ """Serialize a Pydantic model into the JSON-schema string this backend's CLI expects.
156
+
157
+ The default emits the model's plain JSON schema; provider backends
158
+ override to apply their SDK's strict-schema transform.
159
+
160
+ Args:
161
+ model: The Pydantic model describing the structured output.
162
+
163
+ Returns:
164
+ A JSON-schema string suitable for this backend's structured-output argument.
165
+ """
166
+ return json.dumps(model.model_json_schema())
167
+
168
+ def invocation(self, prompt: str, *, model: str, schema_path: str | None, agent: bool) -> Invocation:
169
+ """Build the argv, stdin, and result source for a single invocation.
139
170
 
140
- The default delivers the prompt over stdin; subclasses override to
141
- deliver it inline within the argv.
171
+ The default delivers the prompt over stdin and reads the result from
172
+ stdout; subclasses override to deliver the prompt inline or to read the
173
+ result from a file.
142
174
 
143
175
  Args:
144
176
  prompt: The prompt text to deliver to the CLI.
@@ -147,6 +179,6 @@ class LlmBackend(ABC):
147
179
  agent: Whether the invocation may use tools / agent capabilities.
148
180
 
149
181
  Returns:
150
- A `(argv, stdin_text)` pair; `stdin_text` is `None` when the prompt is delivered inline.
182
+ An `Invocation` carrying the argv, stdin text, and result source.
151
183
  """
152
- return self.build_command(model, schema_path, agent), prompt
184
+ return Invocation(self.build_command(model, schema_path, agent), prompt)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import subprocess
6
7
  from dataclasses import dataclass
7
8
  from typing import TYPE_CHECKING, ClassVar
@@ -89,6 +90,23 @@ class ClaudeCliBackend(LlmBackend):
89
90
  *(["--json-schema", schema_path, "--output-format", "json"] if schema_path else []),
90
91
  ]
91
92
 
93
+ def schema_for(self, model: type[BaseModel]) -> str:
94
+ """Serialize a Pydantic model into Anthropic's structured-output JSON schema.
95
+
96
+ Uses the Anthropic SDK's `transform_schema`, which recursively sets
97
+ `additionalProperties: false` while preserving Pydantic's `required`,
98
+ producing the standard JSON Schema the `claude --json-schema` flag expects.
99
+
100
+ Args:
101
+ model: The Pydantic model describing the structured output.
102
+
103
+ Returns:
104
+ A JSON-schema string passed inline to `--json-schema`.
105
+ """
106
+ from anthropic.lib._parse._transform import transform_schema
107
+
108
+ return json.dumps(transform_schema(model))
109
+
92
110
  def parse_response(self, raw: str, response_model: type[BaseModel] | None) -> str | BaseModel:
93
111
  """Parse `claude` stdout into text or a validated model.
94
112
 
@@ -2,10 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+ import os
5
7
  import subprocess
8
+ import tempfile
6
9
  from typing import TYPE_CHECKING, ClassVar
7
10
 
8
- from spawnllm.backends.base import LlmBackend
11
+ from spawnllm.backends.base import Invocation, LlmBackend
9
12
 
10
13
  if TYPE_CHECKING:
11
14
  from pydantic import BaseModel
@@ -58,11 +61,54 @@ class CodexCliBackend(LlmBackend):
58
61
  *(["--output-schema", schema_path] if schema_path else []),
59
62
  ]
60
63
 
64
+ def invocation(self, prompt: str, *, model: str, schema_path: str | None, agent: bool) -> Invocation:
65
+ """Build the `codex exec` invocation, capturing the final message to a file.
66
+
67
+ `codex exec` streams an interactive log to stdout, so the result is read
68
+ from the `-o`/`--output-last-message` file instead. The result file and
69
+ the schema temp file (when present) are removed after the run.
70
+
71
+ Args:
72
+ prompt: The prompt text, delivered over stdin.
73
+ model: OpenAI model name, e.g. `gpt-5.5`.
74
+ schema_path: Path to a JSON schema file passed to `--output-schema`, or `None`.
75
+ agent: Whether the invocation may use tools / agent capabilities.
76
+
77
+ Returns:
78
+ An `Invocation` whose result is read from the `-o` file.
79
+ """
80
+ fd, result_path = tempfile.mkstemp(suffix=".json")
81
+ os.close(fd)
82
+ return Invocation(
83
+ self.build_command(model, schema_path, agent) + ["-o", result_path],
84
+ prompt,
85
+ result_path=result_path,
86
+ cleanup_paths=(result_path, *((schema_path,) if schema_path else ())),
87
+ )
88
+
89
+ def schema_for(self, model: type[BaseModel]) -> str:
90
+ """Serialize a Pydantic model into an OpenAI strict JSON schema.
91
+
92
+ Uses the OpenAI SDK's `to_strict_json_schema`, which recursively sets
93
+ `additionalProperties: false` and forces every property into `required`
94
+ across `$defs`, `anyOf`, and array items — the form the Responses API
95
+ requires behind `codex exec --output-schema`.
96
+
97
+ Args:
98
+ model: The Pydantic model describing the structured output.
99
+
100
+ Returns:
101
+ A strict JSON-schema string written to the `--output-schema` file.
102
+ """
103
+ from openai.lib._pydantic import to_strict_json_schema
104
+
105
+ return json.dumps(to_strict_json_schema(model))
106
+
61
107
  def parse_response(self, raw: str, response_model: type[BaseModel] | None) -> str | BaseModel:
62
- """Parse `codex` stdout into text or a validated model.
108
+ """Parse the final message `codex` wrote to its `-o` file into text or a validated model.
63
109
 
64
110
  Args:
65
- raw: Raw stdout from the `codex` CLI.
111
+ raw: The final message read from the `-o`/`--output-last-message` file.
66
112
  response_model: Model to validate against, or `None` for raw text.
67
113
 
68
114
  Returns:
@@ -11,7 +11,7 @@ from abc import ABC, abstractmethod
11
11
  from pathlib import Path
12
12
  from typing import TYPE_CHECKING, ClassVar
13
13
 
14
- from spawnllm.backends.base import LlmBackend
14
+ from spawnllm.backends.base import Invocation, LlmBackend
15
15
  from spawnllm.structured import extract_json_block
16
16
 
17
17
  if TYPE_CHECKING:
@@ -48,14 +48,12 @@ class GeminiFamilyBackend(LlmBackend, ABC):
48
48
  def prompt_args(self, text: str) -> list[str]:
49
49
  return ["-p", text]
50
50
 
51
- def invocation(
52
- self, prompt: str, *, model: str, schema_path: str | None, agent: bool
53
- ) -> tuple[list[str], str | None]:
51
+ def invocation(self, prompt: str, *, model: str, schema_path: str | None, agent: bool) -> Invocation:
54
52
  """Build the argv and inline prompt for a single invocation.
55
53
 
56
54
  The prompt travels inline via `-p`; structured output appends the JSON
57
55
  schema and an instruction to emit only conforming JSON. An empty stdin
58
- forces the CLI into non-interactive mode.
56
+ forces the CLI into non-interactive mode, and the result is read from stdout.
59
57
 
60
58
  Args:
61
59
  prompt: The prompt text to deliver inline.
@@ -64,10 +62,10 @@ class GeminiFamilyBackend(LlmBackend, ABC):
64
62
  agent: Whether the invocation may use tools / agent capabilities.
65
63
 
66
64
  Returns:
67
- A `(argv, "")` pair; the empty stdin forces non-interactive output.
65
+ An `Invocation` with an empty stdin that forces non-interactive output.
68
66
  """
69
67
  text = prompt if schema_path is None else f"{prompt}\n\n{SCHEMA_PROMPT}\n{schema_path}"
70
- return self.build_command(model, None, agent) + self.prompt_args(text), ""
68
+ return Invocation(self.build_command(model, None, agent) + self.prompt_args(text), "")
71
69
 
72
70
  def parse_response(self, raw: str, response_model: type[BaseModel] | None) -> str | BaseModel:
73
71
  """Parse Gemini-family stdout into text or a validated model.
@@ -3,11 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ from pathlib import Path
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  from spawnllm.backends.registry import select_backend
9
10
  from spawnllm.proc import run_cli
10
- from spawnllm.structured import resolve_schema_path, schema_for
11
+ from spawnllm.structured import resolve_schema_path
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from pydantic import BaseModel
@@ -24,6 +25,8 @@ def call(
24
25
  model: TModel = "small",
25
26
  agent: bool = False,
26
27
  response_model: type[BaseModel] | None = None,
28
+ cwd: str | None = None,
29
+ timeout: int = 180,
27
30
  ) -> str | BaseModel:
28
31
  """Run one CLI-backed LLM call and parse its response.
29
32
 
@@ -36,13 +39,20 @@ def call(
36
39
  model: Abstract model tier (`small`/`medium`/`large`).
37
40
  agent: Whether the call may use tools / agent capabilities.
38
41
  response_model: Pydantic model for structured output, or `None` for text.
42
+ cwd: Working directory for the CLI process; `None` inherits the caller's.
43
+ timeout: Seconds to wait before the CLI process is killed.
39
44
 
40
45
  Returns:
41
46
  The raw text response, or a validated `response_model` instance.
42
47
  """
43
48
  backend = backend or select_backend(specialty=specialty)
44
- schema = schema_for(response_model) if response_model is not None else None
49
+ schema = backend.schema_for(response_model) if response_model is not None else None
45
50
  schema_path = resolve_schema_path(backend, schema)
46
- argv, stdin = backend.invocation(prompt, model=backend.models[model], schema_path=schema_path, agent=agent)
47
- raw = run_cli(argv, input=stdin, env=os.environ | backend.env(), timeout=180)
48
- return backend.parse_response(raw, response_model)
51
+ inv = backend.invocation(prompt, model=backend.models[model], schema_path=schema_path, agent=agent)
52
+ try:
53
+ stdout = run_cli(inv.argv, input=inv.stdin, env=os.environ | backend.env(), timeout=timeout, cwd=cwd)
54
+ raw = Path(inv.result_path).read_text() if inv.result_path else stdout
55
+ return backend.parse_response(raw, response_model)
56
+ finally:
57
+ for path in inv.cleanup_paths:
58
+ Path(path).unlink(missing_ok=True)
@@ -1,4 +1,4 @@
1
- """Structured-output helpers: JSON-schema build, schema-path resolution, response parsing."""
1
+ """Structured-output helpers: schema-path resolution and response parsing."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -22,23 +22,31 @@ __all__ = [
22
22
  "parse_result_envelope",
23
23
  "parse_structured_output",
24
24
  "resolve_schema_path",
25
- "schema_for",
26
25
  ]
27
26
 
28
27
  JSON_FENCE = re.compile(r"```(?:json)?\s*\n?(.*?)\n?```", re.DOTALL)
29
28
 
30
29
 
31
- def schema_for(model: type[BaseModel]) -> str:
32
- """Serialize a Pydantic model's JSON schema, with `additionalProperties` set to false."""
33
- return json.dumps(model.model_json_schema() | {"additionalProperties": False})
30
+ def first_json_value(source: str) -> str | None:
31
+ """Return the first complete JSON object/array in `source`, or `None` when there is none."""
32
+ decoder = json.JSONDecoder()
33
+ for index, char in enumerate(source):
34
+ if char in "{[":
35
+ try:
36
+ end = decoder.raw_decode(source, index)[1]
37
+ except (json.JSONDecodeError, RecursionError):
38
+ continue
39
+ return source[index:end]
40
+ return None
34
41
 
35
42
 
36
43
  def extract_json_block(text: str) -> str:
37
- """Extract a JSON object/array from model text, tolerating ```json fences or surrounding prose."""
38
- if match := JSON_FENCE.search(text):
39
- return match.group(1).strip()
40
- start = min((i for i in (text.find("{"), text.find("[")) if i != -1), default=0)
41
- return text[start : max(text.rfind("}"), text.rfind("]")) + 1]
44
+ """Extract the first complete JSON value from model text, tolerating ```json fences or surrounding prose."""
45
+ fenced = match.group(1) if (match := JSON_FENCE.search(text)) else None
46
+ for source in (fenced, text):
47
+ if source is not None and (value := first_json_value(source)) is not None:
48
+ return value
49
+ raise ValueError(f"no JSON value found in model output: {text!r}")
42
50
 
43
51
 
44
52
  def resolve_schema_path(backend: LlmBackend, schema: str | None) -> str | 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