spawnllm 0.6.0__tar.gz → 0.6.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.6.0 → spawnllm-0.6.2}/PKG-INFO +2 -1
- {spawnllm-0.6.0 → spawnllm-0.6.2}/pyproject.toml +2 -1
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/__init__.py +2 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/backends/__init__.py +2 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/backends/codex.py +17 -2
- spawnllm-0.6.2/spawnllm/backends/openai_endpoint.py +137 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/spec.py +12 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/types.py +1 -1
- {spawnllm-0.6.0 → spawnllm-0.6.2}/LICENSE +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/README.md +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/__main__.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/backends/base.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/backends/claude.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/backends/gemini.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/backends/mlx.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/backends/registry.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/call.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/cli.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/extract.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/mlx/engine.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/proc.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/py.typed +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/response.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/run.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.2}/spawnllm/structured.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spawnllm
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: Delete your subprocess wrappers around claude, codex, and gemini.
|
|
5
5
|
Keywords:
|
|
6
6
|
Author: Yasyf Mohamedali
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
15
|
Classifier: Typing :: Typed
|
|
16
16
|
Requires-Dist: click>=8
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
17
18
|
Requires-Dist: loguru>=0.7
|
|
18
19
|
Requires-Dist: pydantic>=2
|
|
19
20
|
Requires-Dist: openai>=2.43
|
|
@@ -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.6.
|
|
4
|
+
version = "0.6.2"
|
|
5
5
|
description = "Delete your subprocess wrappers around claude, codex, and gemini."
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "MIT"
|
|
@@ -19,6 +19,7 @@ classifiers = [
|
|
|
19
19
|
requires-python = ">=3.13"
|
|
20
20
|
dependencies = [
|
|
21
21
|
"click>=8",
|
|
22
|
+
"httpx>=0.27",
|
|
22
23
|
"loguru>=0.7",
|
|
23
24
|
"pydantic>=2",
|
|
24
25
|
# Per-backend strict-schema transforms: openai.lib._pydantic.to_strict_json_schema
|
|
@@ -24,6 +24,7 @@ from spawnllm.backends import (
|
|
|
24
24
|
LlmBackend,
|
|
25
25
|
LlmBackends,
|
|
26
26
|
MlxBackend,
|
|
27
|
+
OpenAiEndpointBackend,
|
|
27
28
|
select_backend,
|
|
28
29
|
)
|
|
29
30
|
from spawnllm.call import call, call_sync
|
|
@@ -53,6 +54,7 @@ __all__ = [
|
|
|
53
54
|
"LlmBackend",
|
|
54
55
|
"LlmBackends",
|
|
55
56
|
"MlxBackend",
|
|
57
|
+
"OpenAiEndpointBackend",
|
|
56
58
|
"Output",
|
|
57
59
|
"ProviderName",
|
|
58
60
|
"Response",
|
|
@@ -16,6 +16,7 @@ from spawnllm.backends.claude import ClaudeCliBackend
|
|
|
16
16
|
from spawnllm.backends.codex import CodexCliBackend
|
|
17
17
|
from spawnllm.backends.gemini import AntigravityCliBackend, GeminiCliBackend
|
|
18
18
|
from spawnllm.backends.mlx import MlxBackend
|
|
19
|
+
from spawnllm.backends.openai_endpoint import OpenAiEndpointBackend
|
|
19
20
|
from spawnllm.backends.registry import LlmBackends, select_backend
|
|
20
21
|
|
|
21
22
|
__all__ = [
|
|
@@ -33,5 +34,6 @@ __all__ = [
|
|
|
33
34
|
"LlmBackend",
|
|
34
35
|
"LlmBackends",
|
|
35
36
|
"MlxBackend",
|
|
37
|
+
"OpenAiEndpointBackend",
|
|
36
38
|
"select_backend",
|
|
37
39
|
]
|
|
@@ -25,8 +25,13 @@ class CodexCliBackend(CliBackend):
|
|
|
25
25
|
ephemeral session in a read-only sandbox; `invocation` resolves the schema to
|
|
26
26
|
a temp file and captures the final message to an `-o` file. The argv carries
|
|
27
27
|
`--skip-git-repo-check` so a run in an untrusted or non-git `cwd` is not refused —
|
|
28
|
-
the sandbox already confines it, so the trust gate adds nothing here.
|
|
29
|
-
`
|
|
28
|
+
the sandbox already confines it, so the trust gate adds nothing here. The argv pins
|
|
29
|
+
`service_tier=fast` by default — `--ignore-user-config` discards a user-level tier pin,
|
|
30
|
+
and the standard tier turns long prompts into multi-minute runs;
|
|
31
|
+
`CodexConfig(service_tier=None)` drops the flag (an isolated run still ignores the
|
|
32
|
+
user-level config, so a `config.toml` tier pin needs `isolated=False` too).
|
|
33
|
+
`--color never` keeps ANSI codes out of the streamed log. An optional `CodexConfig`
|
|
34
|
+
overrides the sandbox and re-enables Codex hooks or MCP servers.
|
|
30
35
|
|
|
31
36
|
Attributes:
|
|
32
37
|
models: Mapping from abstract model size to an OpenAI model name.
|
|
@@ -73,9 +78,19 @@ class CodexCliBackend(CliBackend):
|
|
|
73
78
|
"--sandbox",
|
|
74
79
|
cfg.sandbox or "read-only",
|
|
75
80
|
"--skip-git-repo-check",
|
|
81
|
+
"--color",
|
|
82
|
+
"never",
|
|
76
83
|
"--model",
|
|
77
84
|
model,
|
|
78
85
|
*(["-c", f"model_reasoning_effort={effort}"] if effort else []),
|
|
86
|
+
*(["-c", f"service_tier={cfg.service_tier}"] if cfg.service_tier is not None else []),
|
|
87
|
+
# codex parses -c values as TOML with a raw-string fallback; a JSON string is a
|
|
88
|
+
# valid TOML basic string, so words like `true` don't coerce to booleans.
|
|
89
|
+
*(
|
|
90
|
+
["-c", f"developer_instructions={json.dumps(cfg.developer_instructions, ensure_ascii=False)}"]
|
|
91
|
+
if cfg.developer_instructions is not None
|
|
92
|
+
else []
|
|
93
|
+
),
|
|
79
94
|
*(["--ignore-user-config"] if spec.isolated else []),
|
|
80
95
|
*(
|
|
81
96
|
[]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""HTTP backend for any OpenAI-compatible `/chat/completions` endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from spawnllm.backends.base import BackendReady, LlmBackend
|
|
11
|
+
from spawnllm.structured import extract_json_block
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from spawnllm.backends.base import BackendStatus
|
|
17
|
+
from spawnllm.response import Response
|
|
18
|
+
from spawnllm.spec import RunSpec
|
|
19
|
+
from spawnllm.types import ProviderName, TModel
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OpenAiEndpointBackend(LlmBackend):
|
|
23
|
+
"""`LlmBackend` that POSTs to an OpenAI-compatible `/chat/completions` endpoint.
|
|
24
|
+
|
|
25
|
+
Unlike the CLI backends this drives a raw `httpx` request rather than a
|
|
26
|
+
subprocess, and it is never auto-selected; the consumer constructs it
|
|
27
|
+
explicitly with a `base_url` and a literal `model`. Every abstract tier maps
|
|
28
|
+
to that one pinned `model`, and `RunSpec.model` is ignored at request time —
|
|
29
|
+
the endpoint always serves the constructor's `model`. Structured output rides
|
|
30
|
+
on `response_format` with a strict `json_schema`; text output reads
|
|
31
|
+
`choices[0].message.content`.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
base_url: Root URL of the server; `/chat/completions` is appended.
|
|
35
|
+
model: The literal model id sent in every request body.
|
|
36
|
+
api_key: Bearer token for the `Authorization` header; defaults to
|
|
37
|
+
`"local"` for self-hosted servers that ignore it.
|
|
38
|
+
transport: Async transport injected into the `httpx.AsyncClient` used by
|
|
39
|
+
`aexecute` — e.g. a record/replay caching transport; `None` uses
|
|
40
|
+
httpx's default transport. The synchronous `execute` path always uses
|
|
41
|
+
the default transport.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> backend = OpenAiEndpointBackend("http://localhost:8000/v1", "qwen3")
|
|
45
|
+
>>> backend.execute(RunSpec(prompt="ping", model="qwen3"))
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
provider: ClassVar[ProviderName] = "openai_endpoint"
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self, base_url: str, model: str, *, api_key: str = "local", transport: httpx.AsyncBaseTransport | None = None
|
|
52
|
+
) -> None:
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.model = model
|
|
55
|
+
self.api_key = api_key
|
|
56
|
+
self.transport = transport
|
|
57
|
+
self.models: dict[TModel, str] = {"small": model, "medium": model, "large": model}
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def url(self) -> str:
|
|
61
|
+
return f"{self.base_url}/chat/completions"
|
|
62
|
+
|
|
63
|
+
def headers(self) -> dict[str, str]:
|
|
64
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
65
|
+
|
|
66
|
+
def payload(self, spec: RunSpec) -> dict[str, object]:
|
|
67
|
+
base: dict[str, object] = {"model": self.model, "messages": [{"role": "user", "content": spec.prompt}]}
|
|
68
|
+
if (schema := self.schema_arg(spec)) is None:
|
|
69
|
+
return base
|
|
70
|
+
json_schema = {"name": "response", "strict": True, "schema": json.loads(schema)}
|
|
71
|
+
return base | {"response_format": {"type": "json_schema", "json_schema": json_schema}}
|
|
72
|
+
|
|
73
|
+
def resolve(self, resp: httpx.Response, spec: RunSpec) -> Response:
|
|
74
|
+
return self.to_response(
|
|
75
|
+
resp.text,
|
|
76
|
+
returncode=0 if resp.is_success else resp.status_code,
|
|
77
|
+
stderr="" if resp.is_success else resp.text,
|
|
78
|
+
spec=spec,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def aexecute(self, spec: RunSpec) -> Response:
|
|
82
|
+
async with httpx.AsyncClient(timeout=spec.timeout, transport=self.transport) as client:
|
|
83
|
+
resp = await client.post(self.url, headers=self.headers(), json=self.payload(spec))
|
|
84
|
+
return self.resolve(resp, spec)
|
|
85
|
+
|
|
86
|
+
def execute(self, spec: RunSpec) -> Response:
|
|
87
|
+
with httpx.Client(timeout=spec.timeout) as client:
|
|
88
|
+
resp = client.post(self.url, headers=self.headers(), json=self.payload(spec))
|
|
89
|
+
return self.resolve(resp, spec)
|
|
90
|
+
|
|
91
|
+
def schema_for(self, model: type[BaseModel]) -> str:
|
|
92
|
+
"""Serialize a Pydantic model into an OpenAI strict JSON schema for `response_format`.
|
|
93
|
+
|
|
94
|
+
Uses the OpenAI SDK's `to_strict_json_schema`, which recursively sets
|
|
95
|
+
`additionalProperties: false` and forces every property into `required`
|
|
96
|
+
across `$defs`, `anyOf`, and array items — the form a `json_schema`
|
|
97
|
+
`response_format` requires.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
model: The Pydantic model describing the structured output.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A strict JSON-schema string embedded in the request body.
|
|
104
|
+
"""
|
|
105
|
+
from openai.lib._pydantic import to_strict_json_schema
|
|
106
|
+
|
|
107
|
+
return json.dumps(to_strict_json_schema(model))
|
|
108
|
+
|
|
109
|
+
def result_text(self, raw: str) -> str:
|
|
110
|
+
"""Return `choices[0].message.content` from the chat-completion response body."""
|
|
111
|
+
return json.loads(raw)["choices"][0]["message"]["content"]
|
|
112
|
+
|
|
113
|
+
def result_value(self, raw: str) -> object:
|
|
114
|
+
"""Return the JSON value parsed from the message content, tolerating fences or surrounding prose."""
|
|
115
|
+
return json.loads(extract_json_block(self.result_text(raw)))
|
|
116
|
+
|
|
117
|
+
def envelope_error(self, raw: str) -> str | None:
|
|
118
|
+
"""Return the message from an OpenAI `{"error": {...}}` body carried on a 2xx response, else `None`."""
|
|
119
|
+
match json.loads(raw):
|
|
120
|
+
case {"error": {"message": str(msg)}}:
|
|
121
|
+
return msg
|
|
122
|
+
case {"error": str(msg)}:
|
|
123
|
+
return msg
|
|
124
|
+
case _:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def env(self, _spec: RunSpec) -> dict[str, str]:
|
|
128
|
+
"""Return no extra environment variables; the endpoint is reached over HTTP with nothing to isolate."""
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
def is_authenticated(self, *, timeout: int) -> bool:
|
|
132
|
+
"""Report `True`; credentials travel inline as the `Authorization` bearer token on every request."""
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def check_status(self, *, timeout: int = 10) -> BackendStatus:
|
|
136
|
+
"""Report `BackendReady`; the endpoint is reached per-request and carries its own auth."""
|
|
137
|
+
return BackendReady(binary="openai_endpoint")
|
|
@@ -44,6 +44,16 @@ class ClaudeConfig:
|
|
|
44
44
|
class CodexConfig:
|
|
45
45
|
"""Codex CLI knobs applied only by the Codex backend.
|
|
46
46
|
|
|
47
|
+
`service_tier` (default `"fast"`) is emitted as `-c service_tier=<value>` on
|
|
48
|
+
every invocation, isolated or not: isolated runs pass `--ignore-user-config`,
|
|
49
|
+
which drops a `service_tier` pin in the user's `~/.codex/config.toml`, and the
|
|
50
|
+
standard tier turns long prompts into multi-minute runs. Set it to `None` to
|
|
51
|
+
drop the flag; an isolated run still passes `--ignore-user-config`, so a
|
|
52
|
+
user-level tier pin applies only with `isolated=False`. `developer_instructions` injects
|
|
53
|
+
the system-prompt layer via `-c developer_instructions=<value>`, serialized as
|
|
54
|
+
a TOML string so any text — multi-line, or TOML-ambiguous words like `true` —
|
|
55
|
+
arrives as a string.
|
|
56
|
+
|
|
47
57
|
Example:
|
|
48
58
|
>>> CodexConfig(sandbox="read-only", enable_mcp=True)
|
|
49
59
|
"""
|
|
@@ -51,6 +61,8 @@ class CodexConfig:
|
|
|
51
61
|
sandbox: str | None = None
|
|
52
62
|
enable_hooks: bool = False
|
|
53
63
|
enable_mcp: bool = False
|
|
64
|
+
service_tier: str | None = "fast"
|
|
65
|
+
developer_instructions: str | None = None
|
|
54
66
|
|
|
55
67
|
|
|
56
68
|
@dataclass(frozen=True, slots=True)
|
|
@@ -12,5 +12,5 @@ TSpecialty = Literal["debugging", "review", "general"]
|
|
|
12
12
|
TModel = Literal["small", "medium", "large"]
|
|
13
13
|
"""Abstract model tier; each backend maps it to a provider-specific model name."""
|
|
14
14
|
|
|
15
|
-
ProviderName = Literal["claude", "codex", "gemini", "antigravity", "mlx"]
|
|
15
|
+
ProviderName = Literal["claude", "codex", "gemini", "antigravity", "mlx", "openai_endpoint"]
|
|
16
16
|
"""Backend provider identifier; keys the per-backend `provider_configs` on a `RunSpec`."""
|
|
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
|