spawnllm 0.6.0__tar.gz → 0.6.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.6.0 → spawnllm-0.6.1}/PKG-INFO +2 -1
- {spawnllm-0.6.0 → spawnllm-0.6.1}/pyproject.toml +2 -1
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/__init__.py +2 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/backends/__init__.py +2 -0
- spawnllm-0.6.1/spawnllm/backends/openai_endpoint.py +137 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/types.py +1 -1
- {spawnllm-0.6.0 → spawnllm-0.6.1}/LICENSE +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/README.md +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/__main__.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/backends/base.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/backends/claude.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/backends/codex.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/backends/gemini.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/backends/mlx.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/backends/registry.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/call.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/cli.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/extract.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/mlx/__init__.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/mlx/codec.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/mlx/engine.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/mlx/fuse.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/mlx/patches.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/proc.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/py.typed +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/response.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/run.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/spawnllm/spec.py +0 -0
- {spawnllm-0.6.0 → spawnllm-0.6.1}/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.1
|
|
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.1"
|
|
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
|
]
|
|
@@ -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")
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|