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.
- {spawnllm-0.2.0 → spawnllm-0.3.1}/PKG-INFO +60 -6
- {spawnllm-0.2.0 → spawnllm-0.3.1}/README.md +57 -5
- {spawnllm-0.2.0 → spawnllm-0.3.1}/pyproject.toml +6 -1
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/__init__.py +2 -2
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/backends/__init__.py +2 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/backends/base.py +40 -8
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/backends/claude.py +18 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/backends/codex.py +49 -3
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/backends/gemini.py +5 -7
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/call.py +15 -5
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/structured.py +18 -10
- {spawnllm-0.2.0 → spawnllm-0.3.1}/LICENSE +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/__main__.py +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/backends/registry.py +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/cli.py +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/mlx/engine.py +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/proc.py +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/spawnllm/py.typed +0 -0
- {spawnllm-0.2.0 → spawnllm-0.3.1}/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.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
|
-
|
|
88
|
+
See which backends are installed and authenticated, and which one auto-selection picks:
|
|
87
89
|
|
|
88
90
|
```bash
|
|
89
|
-
uvx spawnllm
|
|
91
|
+
uvx spawnllm status
|
|
90
92
|
```
|
|
91
93
|
|
|
92
94
|
```
|
|
93
|
-
claude
|
|
94
|
-
codex
|
|
95
|
-
|
|
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
|
-
|
|
41
|
+
See which backends are installed and authenticated, and which one auto-selection picks:
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
uvx spawnllm
|
|
44
|
+
uvx spawnllm status
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
```
|
|
48
|
-
claude
|
|
49
|
-
codex
|
|
50
|
-
|
|
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.
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
141
|
-
deliver
|
|
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
|
-
|
|
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`
|
|
108
|
+
"""Parse the final message `codex` wrote to its `-o` file into text or a validated model.
|
|
63
109
|
|
|
64
110
|
Args:
|
|
65
|
-
raw:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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:
|
|
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
|
|
32
|
-
"""
|
|
33
|
-
|
|
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
|
|
38
|
-
if match := JSON_FENCE.search(text)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|