polyllmkit 0.1.2__tar.gz → 0.2.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.
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/PKG-INFO +1 -1
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/pyproject.toml +6 -1
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/pyproject.toml.orig +8 -1
- polyllmkit-0.2.1/src/llmkit/bridge/_spawn.py +178 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/anthropic.py +4 -1
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/claude_code.py +24 -7
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/google.py +5 -1
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/openai.py +3 -1
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/bridge.py +39 -24
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/cli.py +9 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/client.py +24 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/config.py +9 -0
- polyllmkit-0.2.1/src/llmkit/bridge/structured.py +120 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/__init__.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/__init__.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/__main__.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/__init__.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/sinks.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/stream.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/__init__.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/render/__init__.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/render/__main__.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/render/cli.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/render/stream.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/view/__init__.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/view/__main__.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/view/app.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/view/cli.py +0 -0
- {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: polyllmkit
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Streaming, multi-provider LLM bridge + markdown rendering toolkit for terminal apps.
|
|
5
5
|
Requires-Dist: openai>=1.0 ; extra == 'all'
|
|
6
6
|
Requires-Dist: claude-agent-sdk>=0.2 ; extra == 'all'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "polyllmkit"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Streaming, multi-provider LLM bridge + markdown rendering toolkit for terminal apps."
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
dependencies = []
|
|
@@ -30,8 +30,13 @@ build-backend = "uv_build"
|
|
|
30
30
|
module-root = "src"
|
|
31
31
|
module-name = "llmkit"
|
|
32
32
|
|
|
33
|
+
[tool.pyright]
|
|
34
|
+
venvPath = "."
|
|
35
|
+
venv = ".venv"
|
|
36
|
+
|
|
33
37
|
[dependency-groups]
|
|
34
38
|
dev = [
|
|
35
39
|
"ruff>=0.6",
|
|
36
40
|
"mypy>=1.10",
|
|
41
|
+
"pytest>=9.2.0",
|
|
37
42
|
]
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# module-name below): consumers keep `import llmkit`, `pip install polyllmkit`.
|
|
5
5
|
[project]
|
|
6
6
|
name = "polyllmkit"
|
|
7
|
-
version = "0.1
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Streaming, multi-provider LLM bridge + markdown rendering toolkit for terminal apps."
|
|
9
9
|
requires-python = ">=3.11" # tomllib (bridge.config) is stdlib only from 3.11
|
|
10
10
|
# Base install pulls nothing — pick the subpackage(s) you need via extras.
|
|
@@ -48,4 +48,11 @@ module-name = "llmkit"
|
|
|
48
48
|
dev = [
|
|
49
49
|
"ruff>=0.6",
|
|
50
50
|
"mypy>=1.10",
|
|
51
|
+
"pytest>=9.2.0",
|
|
51
52
|
]
|
|
53
|
+
|
|
54
|
+
# Resolve imports from the project venv (not an activated $VIRTUAL_ENV, which
|
|
55
|
+
# may hold an older installed llmkit and shadow src/).
|
|
56
|
+
[tool.pyright]
|
|
57
|
+
venvPath = "."
|
|
58
|
+
venv = ".venv"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Start child processes WITHOUT fork().
|
|
2
|
+
|
|
3
|
+
Why: on macOS, once a process has used Apple's Network.framework (a host's
|
|
4
|
+
DuckDB Quack client does, through its HTTP stack), the framework's fork handler
|
|
5
|
+
(``nw_settings_child_has_forked``) segfaults the child of every later fork()
|
|
6
|
+
before it can exec. llmkit is embedded in such hosts (cribsheet's daemon), so
|
|
7
|
+
an adapter that launches a program — ``claude_code`` starting the ``claude``
|
|
8
|
+
CLI — must never fork. posix_spawn runs no fork handlers.
|
|
9
|
+
|
|
10
|
+
CPython takes the posix_spawn path only when the program is an explicit path,
|
|
11
|
+
there is no ``cwd``, file descriptors are inherited (macOS has no posix_spawn
|
|
12
|
+
closefrom), and none of preexec_fn / start_new_session / pass_fds / umask /
|
|
13
|
+
process_group / user / group are set. :func:`open_process` arranges all of it:
|
|
14
|
+
|
|
15
|
+
- the program is resolved to an absolute path (a missing one raises
|
|
16
|
+
FileNotFoundError, exactly as ``subprocess`` would);
|
|
17
|
+
- ``close_fds=False``: safe, because Python's own fds are non-inheritable
|
|
18
|
+
(PEP 446);
|
|
19
|
+
- a ``cwd`` is applied by ``/bin/sh`` (``cd``, then ``exec`` the program);
|
|
20
|
+
- options that would force fork() are refused, not silently forked.
|
|
21
|
+
|
|
22
|
+
The Claude Agent SDK spawns the CLI itself, through ``anyio.open_process``,
|
|
23
|
+
which has no ``close_fds`` parameter and so always forks. :func:`cli_transport`
|
|
24
|
+
returns an SDK transport whose ``connect`` routes that call (and the CLI version
|
|
25
|
+
probe) through :func:`open_process` instead; any other user of the SDK in the
|
|
26
|
+
same process is untouched.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import contextvars
|
|
32
|
+
import errno
|
|
33
|
+
import os
|
|
34
|
+
import shutil
|
|
35
|
+
from collections.abc import Sequence
|
|
36
|
+
from subprocess import PIPE
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
_SH = "/bin/sh"
|
|
40
|
+
# $1 is the directory; the rest is the program and its arguments.
|
|
41
|
+
_CD_EXEC = 'cd -- "$1" || exit 127; shift; exec "$@"'
|
|
42
|
+
# Options that force CPython back onto fork(), with the default that doesn't
|
|
43
|
+
# (anyio.open_process forwards several of these at their defaults).
|
|
44
|
+
_FORK_FREE_DEFAULTS: dict[str, Any] = {
|
|
45
|
+
"preexec_fn": None,
|
|
46
|
+
"start_new_session": False,
|
|
47
|
+
"pass_fds": (),
|
|
48
|
+
"process_group": None,
|
|
49
|
+
"user": None,
|
|
50
|
+
"group": None,
|
|
51
|
+
"extra_groups": None,
|
|
52
|
+
"umask": -1,
|
|
53
|
+
"executable": None,
|
|
54
|
+
"startupinfo": None,
|
|
55
|
+
"creationflags": 0,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Set while a :func:`cli_transport` is connecting: only then does the SDK's
|
|
59
|
+
# ``anyio.open_process`` go through :func:`open_process`.
|
|
60
|
+
_SPAWNING: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
61
|
+
"llmkit_spawning", default=False
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def spawn_argv(
|
|
66
|
+
cmd: Sequence[str | os.PathLike[str]], cwd: str | os.PathLike[str] | None = None
|
|
67
|
+
) -> list[str]:
|
|
68
|
+
"""``cmd`` rewritten so CPython can posix_spawn it: an absolute program
|
|
69
|
+
path, and ``cwd`` (if any) applied by ``/bin/sh`` rather than by Popen."""
|
|
70
|
+
if not cmd:
|
|
71
|
+
raise ValueError("empty command")
|
|
72
|
+
args = [os.fspath(a) for a in cmd]
|
|
73
|
+
prog = args[0]
|
|
74
|
+
exe = prog if os.path.dirname(prog) else shutil.which(prog)
|
|
75
|
+
if exe is None:
|
|
76
|
+
raise FileNotFoundError(
|
|
77
|
+
errno.ENOENT, f"No such file or directory: {prog!r}", prog
|
|
78
|
+
)
|
|
79
|
+
exe = os.path.abspath(exe)
|
|
80
|
+
if cwd is None:
|
|
81
|
+
return [exe, *args[1:]]
|
|
82
|
+
d = os.fspath(cwd)
|
|
83
|
+
if not os.path.isdir(d):
|
|
84
|
+
raise FileNotFoundError(errno.ENOENT, f"No such directory: {d!r}", d)
|
|
85
|
+
return [_SH, "-c", _CD_EXEC, "llmkit", d, exe, *args[1:]]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def open_process(
|
|
89
|
+
command: Sequence[str | os.PathLike[str]],
|
|
90
|
+
*,
|
|
91
|
+
stdin: Any = PIPE,
|
|
92
|
+
stdout: Any = PIPE,
|
|
93
|
+
stderr: Any = PIPE,
|
|
94
|
+
cwd: str | os.PathLike[str] | None = None,
|
|
95
|
+
env: Any = None,
|
|
96
|
+
**kwargs: Any,
|
|
97
|
+
) -> Any:
|
|
98
|
+
"""``anyio.open_process``, spawned without fork(). Returns an
|
|
99
|
+
``anyio.abc.Process``."""
|
|
100
|
+
if isinstance(command, (str, bytes)):
|
|
101
|
+
raise ValueError("llmkit spawn takes an argv sequence, not a shell string")
|
|
102
|
+
forking = sorted(
|
|
103
|
+
k
|
|
104
|
+
for k, v in kwargs.items()
|
|
105
|
+
if k not in _FORK_FREE_DEFAULTS or v != _FORK_FREE_DEFAULTS[k]
|
|
106
|
+
)
|
|
107
|
+
if forking:
|
|
108
|
+
raise ValueError(f"llmkit spawn refuses options that force fork(): {forking}")
|
|
109
|
+
# anyio.open_process can't pass close_fds; its backend forwards any kwarg
|
|
110
|
+
# to Popen (asyncio and trio alike). Not re-exported publicly by anyio.
|
|
111
|
+
from anyio._core._eventloop import get_async_backend
|
|
112
|
+
|
|
113
|
+
return await get_async_backend().open_process(
|
|
114
|
+
spawn_argv(command, cwd),
|
|
115
|
+
stdin=stdin,
|
|
116
|
+
stdout=stdout,
|
|
117
|
+
stderr=stderr,
|
|
118
|
+
env=env,
|
|
119
|
+
close_fds=False,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _AnyioProxy:
|
|
124
|
+
"""Stands in for ``anyio`` inside the SDK's transport module: everything
|
|
125
|
+
passes through, except ``open_process`` while a spawn transport connects."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, real: Any) -> None:
|
|
128
|
+
self._real = real
|
|
129
|
+
|
|
130
|
+
def __getattr__(self, name: str) -> Any:
|
|
131
|
+
return getattr(self._real, name)
|
|
132
|
+
|
|
133
|
+
async def open_process(self, command: Any, **kwargs: Any) -> Any:
|
|
134
|
+
if _SPAWNING.get():
|
|
135
|
+
return await open_process(command, **kwargs)
|
|
136
|
+
return await self._real.open_process(command, **kwargs)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_transport_cls: Any = None
|
|
140
|
+
# The SDK transport module's `import anyio` binding.
|
|
141
|
+
_SDK_ANYIO = "anyio"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _spawn_transport_cls() -> Any:
|
|
145
|
+
global _transport_cls
|
|
146
|
+
if _transport_cls is not None:
|
|
147
|
+
return _transport_cls
|
|
148
|
+
from claude_agent_sdk._internal.transport import subprocess_cli as sc
|
|
149
|
+
|
|
150
|
+
if not hasattr(sc, _SDK_ANYIO) or not hasattr(sc, "SubprocessCLITransport"):
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
"llmkit: claude_agent_sdk's subprocess transport changed shape; "
|
|
153
|
+
"can't guarantee a fork-free CLI launch"
|
|
154
|
+
)
|
|
155
|
+
# The module's own `import anyio` binding, swapped for the proxy (attribute
|
|
156
|
+
# access by name: it isn't a re-export).
|
|
157
|
+
real = getattr(sc, _SDK_ANYIO)
|
|
158
|
+
if not isinstance(real, _AnyioProxy):
|
|
159
|
+
setattr(sc, _SDK_ANYIO, _AnyioProxy(real))
|
|
160
|
+
|
|
161
|
+
class SpawnCLITransport(sc.SubprocessCLITransport): # type: ignore[misc, name-defined]
|
|
162
|
+
"""The SDK's CLI transport, launching the CLI with posix_spawn."""
|
|
163
|
+
|
|
164
|
+
async def connect(self) -> None:
|
|
165
|
+
token = _SPAWNING.set(True)
|
|
166
|
+
try:
|
|
167
|
+
await super().connect()
|
|
168
|
+
finally:
|
|
169
|
+
_SPAWNING.reset(token)
|
|
170
|
+
|
|
171
|
+
_transport_cls = SpawnCLITransport
|
|
172
|
+
return _transport_cls
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cli_transport(prompt: Any, options: Any) -> Any:
|
|
176
|
+
"""A Claude Agent SDK transport for ``sdk.query(..., transport=...)`` that
|
|
177
|
+
starts the ``claude`` CLI without fork()."""
|
|
178
|
+
return _spawn_transport_cls()(prompt=prompt, options=options)
|
|
@@ -33,7 +33,7 @@ from typing import Any, Optional
|
|
|
33
33
|
from urllib.parse import urlsplit, urlunsplit
|
|
34
34
|
|
|
35
35
|
from ..bridge import DEFAULT_ENDPOINT, ChatRequest, _Emitter
|
|
36
|
-
from ..client import resolved_headers
|
|
36
|
+
from ..client import Deadline, resolved_headers, timeout_kwargs
|
|
37
37
|
from ..config import Provider
|
|
38
38
|
|
|
39
39
|
|
|
@@ -102,6 +102,7 @@ def stream_anthropic(
|
|
|
102
102
|
|
|
103
103
|
kwargs: dict[str, Any] = dict(
|
|
104
104
|
api_key=_resolve_api_key(provider),
|
|
105
|
+
**timeout_kwargs(provider),
|
|
105
106
|
)
|
|
106
107
|
# The bridge fills endpoint with the openai/ollama default; only forward an
|
|
107
108
|
# endpoint the user actually customised (an Anthropic-compatible gateway),
|
|
@@ -137,8 +138,10 @@ def stream_anthropic(
|
|
|
137
138
|
else:
|
|
138
139
|
create["thinking"] = _thinking_config(provider.enable_thinking)
|
|
139
140
|
|
|
141
|
+
deadline = Deadline(provider.timeout)
|
|
140
142
|
with client.messages.stream(**create) as stream:
|
|
141
143
|
for event in stream:
|
|
144
|
+
deadline.check()
|
|
142
145
|
# Any event is a sign of life — fire TTFT before the first non-empty
|
|
143
146
|
# delta (mirrors the openai adapter's explicit signal).
|
|
144
147
|
emitter.signal_streaming()
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
import os
|
|
21
21
|
from typing import Any
|
|
22
22
|
|
|
23
|
+
from .._spawn import cli_transport
|
|
23
24
|
from ..bridge import ChatRequest, _Emitter
|
|
24
25
|
from ..config import Provider
|
|
25
26
|
|
|
@@ -131,7 +132,16 @@ def stream_claude_code(
|
|
|
131
132
|
sdk = _require_sdk()
|
|
132
133
|
import anyio # bundled with the SDK
|
|
133
134
|
|
|
134
|
-
anyio.run(
|
|
135
|
+
anyio.run(_bounded, provider.timeout, _run(sdk, provider, request, emitter))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _bounded(timeout: float | None, coro: Any) -> None:
|
|
139
|
+
"""Await ``coro``, cancelling it after ``timeout`` seconds (TimeoutError).
|
|
140
|
+
Cancellation unwinds the SDK's query, which terminates the CLI."""
|
|
141
|
+
import anyio
|
|
142
|
+
|
|
143
|
+
with anyio.fail_after(timeout):
|
|
144
|
+
await coro
|
|
135
145
|
|
|
136
146
|
|
|
137
147
|
async def _run(
|
|
@@ -139,7 +149,10 @@ async def _run(
|
|
|
139
149
|
) -> None:
|
|
140
150
|
options = _build_options(sdk, provider, request)
|
|
141
151
|
saw_delta = False
|
|
142
|
-
|
|
152
|
+
transport = cli_transport(request.user, options)
|
|
153
|
+
async for message in sdk.query(
|
|
154
|
+
prompt=request.user, options=options, transport=transport
|
|
155
|
+
):
|
|
143
156
|
if isinstance(message, sdk.StreamEvent):
|
|
144
157
|
if _handle_event(message.event, emitter):
|
|
145
158
|
saw_delta = True
|
|
@@ -159,9 +172,10 @@ def structured_claude_code(
|
|
|
159
172
|
"""Structured output via the SDK's native ``output_format`` (set in
|
|
160
173
|
``_build_options`` when the request carries a schema). Runs the single-turn
|
|
161
174
|
query and returns ``(exit_code, structured_output)`` from the ResultMessage —
|
|
162
|
-
the parsed object matching the schema,
|
|
163
|
-
none, or ``(1, None)`` on an
|
|
164
|
-
|
|
175
|
+
the parsed object matching the schema, ``(0, None)`` if the model produced
|
|
176
|
+
none (``chat_structured`` turns that into a failure), or ``(1, None)`` on an
|
|
177
|
+
SDK-reported error. No text parsing: the CLI does the schema enforcement +
|
|
178
|
+
JSON parse and hands back a Python object."""
|
|
165
179
|
sdk = _require_sdk()
|
|
166
180
|
import anyio # bundled with the SDK
|
|
167
181
|
|
|
@@ -169,10 +183,13 @@ def structured_claude_code(
|
|
|
169
183
|
|
|
170
184
|
async def _run_structured() -> None:
|
|
171
185
|
options = _build_options(sdk, provider, request)
|
|
172
|
-
|
|
186
|
+
transport = cli_transport(request.user, options)
|
|
187
|
+
async for message in sdk.query(
|
|
188
|
+
prompt=request.user, options=options, transport=transport
|
|
189
|
+
):
|
|
173
190
|
if isinstance(message, sdk.ResultMessage):
|
|
174
191
|
out["data"] = getattr(message, "structured_output", None)
|
|
175
192
|
out["error"] = bool(getattr(message, "is_error", False))
|
|
176
193
|
|
|
177
|
-
anyio.run(_run_structured)
|
|
194
|
+
anyio.run(_bounded, provider.timeout, _run_structured())
|
|
178
195
|
return (1 if out["error"] else 0), out["data"]
|
|
@@ -23,7 +23,7 @@ import os
|
|
|
23
23
|
from typing import Any, Optional
|
|
24
24
|
|
|
25
25
|
from ..bridge import DEFAULT_ENDPOINT, ChatRequest, _Emitter
|
|
26
|
-
from ..client import resolved_headers
|
|
26
|
+
from ..client import Deadline, resolved_headers
|
|
27
27
|
from ..config import Provider
|
|
28
28
|
|
|
29
29
|
|
|
@@ -76,6 +76,8 @@ def stream_google(provider: Provider, request: ChatRequest, emitter: _Emitter) -
|
|
|
76
76
|
headers = resolved_headers(provider)
|
|
77
77
|
if headers:
|
|
78
78
|
http_opts["headers"] = headers
|
|
79
|
+
if provider.timeout is not None:
|
|
80
|
+
http_opts["timeout"] = int(provider.timeout * 1000) # milliseconds
|
|
79
81
|
if http_opts:
|
|
80
82
|
client_kwargs["http_options"] = types.HttpOptions(**http_opts)
|
|
81
83
|
client = genai.Client(**client_kwargs)
|
|
@@ -87,9 +89,11 @@ def stream_google(provider: Provider, request: ChatRequest, emitter: _Emitter) -
|
|
|
87
89
|
thinking_config=_thinking_config(types, provider.enable_thinking),
|
|
88
90
|
)
|
|
89
91
|
|
|
92
|
+
deadline = Deadline(provider.timeout)
|
|
90
93
|
for chunk in client.models.generate_content_stream(
|
|
91
94
|
model=provider.model, contents=request.user, config=config
|
|
92
95
|
):
|
|
96
|
+
deadline.check()
|
|
93
97
|
emitter.signal_streaming()
|
|
94
98
|
for candidate in getattr(chunk, "candidates", None) or []:
|
|
95
99
|
content = getattr(candidate, "content", None)
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from ..client import build_client, enable_thinking_extra
|
|
7
|
+
from ..client import Deadline, build_client, enable_thinking_extra
|
|
8
8
|
from ..config import Provider
|
|
9
9
|
|
|
10
10
|
# Imported under TYPE_CHECKING-free runtime: bridge imports this lazily, so a
|
|
@@ -42,7 +42,9 @@ def stream_openai(provider: Provider, request: ChatRequest, emitter: _Emitter) -
|
|
|
42
42
|
kwargs.pop("extra_body", None)
|
|
43
43
|
kwargs["stream"] = True
|
|
44
44
|
|
|
45
|
+
deadline = Deadline(provider.timeout)
|
|
45
46
|
for chunk in client.chat.completions.create(**kwargs):
|
|
47
|
+
deadline.check()
|
|
46
48
|
if not chunk.choices:
|
|
47
49
|
continue
|
|
48
50
|
# Fire "streaming" on the FIRST chunk that has choices — even a
|
|
@@ -15,14 +15,16 @@ failure semantics. ``complete`` (FIM) remains ``openai-compatible``-only.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import contextlib
|
|
18
19
|
import dataclasses
|
|
19
20
|
import io
|
|
20
21
|
import os
|
|
21
22
|
import sys
|
|
23
|
+
from collections.abc import Callable
|
|
22
24
|
from dataclasses import dataclass
|
|
23
|
-
from typing import Any,
|
|
25
|
+
from typing import Any, TextIO
|
|
24
26
|
|
|
25
|
-
from .client import build_client
|
|
27
|
+
from .client import Deadline, build_client
|
|
26
28
|
from .config import Config, Provider
|
|
27
29
|
from .sinks import close_sink, open_target
|
|
28
30
|
from .stream import StreamSplitter
|
|
@@ -43,7 +45,7 @@ ADAPTERS = ("openai-compatible", "anthropic", "google", "claude_code")
|
|
|
43
45
|
_COMPLETE_ADAPTERS = frozenset({"openai-compatible"})
|
|
44
46
|
|
|
45
47
|
|
|
46
|
-
def adapter_supports_complete(adapter:
|
|
48
|
+
def adapter_supports_complete(adapter: str | None) -> bool:
|
|
47
49
|
"""Whether ``adapter`` (``None`` → the default) can serve ``complete`` (FIM).
|
|
48
50
|
|
|
49
51
|
Queryable up front so a consumer can disable a FIM keybinding / widget for
|
|
@@ -84,9 +86,9 @@ class ChatRequest:
|
|
|
84
86
|
"""
|
|
85
87
|
|
|
86
88
|
user: str
|
|
87
|
-
system:
|
|
88
|
-
enable_thinking:
|
|
89
|
-
schema:
|
|
89
|
+
system: str | None = None
|
|
90
|
+
enable_thinking: str | None = None
|
|
91
|
+
schema: dict | None = None
|
|
90
92
|
schema_name: str = "emit"
|
|
91
93
|
schema_description: str = ""
|
|
92
94
|
|
|
@@ -119,12 +121,13 @@ def with_defaults(provider: Provider) -> Provider:
|
|
|
119
121
|
enable_thinking=provider.enable_thinking or "auto",
|
|
120
122
|
stop=provider.stop,
|
|
121
123
|
headers=provider.headers,
|
|
124
|
+
timeout=provider.timeout,
|
|
122
125
|
extra=provider.extra,
|
|
123
126
|
)
|
|
124
127
|
|
|
125
128
|
|
|
126
129
|
# ── status events ───────────────────────────────────────────────────────────
|
|
127
|
-
def _open_status(path: str) ->
|
|
130
|
+
def _open_status(path: str) -> TextIO | None:
|
|
128
131
|
"""Open a status file (regular file OR fifo) for line-buffered writes.
|
|
129
132
|
O_RDWR | O_APPEND | O_NONBLOCK so it works against either kind without
|
|
130
133
|
blocking on fifo connect (and without truncating regular files).
|
|
@@ -138,7 +141,7 @@ def _open_status(path: str) -> Optional[TextIO]:
|
|
|
138
141
|
return None
|
|
139
142
|
|
|
140
143
|
|
|
141
|
-
def _write_status(fp:
|
|
144
|
+
def _write_status(fp: TextIO | None, event: str) -> None:
|
|
142
145
|
"""Best-effort status line write. Never raises."""
|
|
143
146
|
if fp is None:
|
|
144
147
|
return
|
|
@@ -156,7 +159,7 @@ class _Emitter:
|
|
|
156
159
|
:meth:`signal_streaming` directly when a chunk arrives that carries
|
|
157
160
|
neither."""
|
|
158
161
|
|
|
159
|
-
def __init__(self, splitter: StreamSplitter, status_fp:
|
|
162
|
+
def __init__(self, splitter: StreamSplitter, status_fp: TextIO | None) -> None:
|
|
160
163
|
self._splitter = splitter
|
|
161
164
|
self._status_fp = status_fp
|
|
162
165
|
self._started = False
|
|
@@ -220,17 +223,13 @@ def _run_chat(
|
|
|
220
223
|
finally:
|
|
221
224
|
# ALWAYS flush — interrupts and exceptions still get the close wrap
|
|
222
225
|
# emitted, so downstream readers see a complete document.
|
|
223
|
-
|
|
226
|
+
with contextlib.suppress(Exception):
|
|
224
227
|
splitter.finish()
|
|
225
|
-
except Exception:
|
|
226
|
-
pass
|
|
227
228
|
close_sink(content_sink)
|
|
228
229
|
close_sink(thinking_sink)
|
|
229
230
|
if status_fp is not None:
|
|
230
|
-
|
|
231
|
+
with contextlib.suppress(OSError):
|
|
231
232
|
status_fp.close()
|
|
232
|
-
except OSError:
|
|
233
|
-
pass
|
|
234
233
|
return rc
|
|
235
234
|
|
|
236
235
|
|
|
@@ -308,31 +307,45 @@ def chat_structured(
|
|
|
308
307
|
status_file: str = "",
|
|
309
308
|
) -> tuple[int, Any]:
|
|
310
309
|
"""Structured output: for a ``request`` carrying a ``schema``, capture the
|
|
311
|
-
model's schema-filled JSON and return ``(exit_code, parsed)``.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
310
|
+
model's schema-filled JSON and return ``(exit_code, parsed)``. A non-zero
|
|
311
|
+
code always comes with ``None``, and ``0`` always comes with an object that
|
|
312
|
+
fits the schema: a reply with no usable JSON is a failure (code 1, reason
|
|
313
|
+
on stderr), never a silent success. The reply is parsed leniently (see
|
|
314
|
+
:mod:`llmkit.bridge.structured`) because an openai-compatible server may
|
|
315
|
+
ignore ``response_format`` and answer in fenced prose. Thinking is off
|
|
316
|
+
(forced tool-use excludes it)."""
|
|
317
|
+
from .structured import StructuredOutputError, conform, parse_structured
|
|
315
318
|
|
|
316
319
|
# claude_code enforces the schema via the SDK's native output_format and returns
|
|
317
320
|
# a parsed object in ResultMessage.structured_output — no text round-trip.
|
|
318
321
|
if provider.adapter == "claude_code":
|
|
319
322
|
from .adapters.claude_code import structured_claude_code
|
|
320
|
-
|
|
323
|
+
code, data = structured_claude_code(with_defaults(provider), request)
|
|
324
|
+
if code != 0:
|
|
325
|
+
return code, None
|
|
326
|
+
try:
|
|
327
|
+
if data is None:
|
|
328
|
+
raise StructuredOutputError("the CLI returned no structured_output")
|
|
329
|
+
return 0, conform(data, request.schema)
|
|
330
|
+
except StructuredOutputError as e:
|
|
331
|
+
print(f"llmkit: structured output: {e}", file=sys.stderr)
|
|
332
|
+
return 1, None
|
|
321
333
|
|
|
322
334
|
code, text = chat_to_str(provider, request, thinking="none", status_file=status_file)
|
|
323
335
|
if code != 0:
|
|
324
336
|
return code, None
|
|
325
337
|
try:
|
|
326
|
-
return
|
|
327
|
-
except
|
|
328
|
-
|
|
338
|
+
return 0, parse_structured(text, request.schema)
|
|
339
|
+
except StructuredOutputError as e:
|
|
340
|
+
print(f"llmkit: structured output: {e}", file=sys.stderr)
|
|
341
|
+
return 1, None
|
|
329
342
|
|
|
330
343
|
|
|
331
344
|
def complete(
|
|
332
345
|
provider: Provider,
|
|
333
346
|
request: CompleteRequest,
|
|
334
347
|
*,
|
|
335
|
-
out:
|
|
348
|
+
out: TextIO | None = None,
|
|
336
349
|
) -> int:
|
|
337
350
|
"""Run a text-completion (FIM) request, streaming text to ``out``
|
|
338
351
|
(default stdout). ``openai-compatible`` only."""
|
|
@@ -360,8 +373,10 @@ def complete(
|
|
|
360
373
|
if request.stop:
|
|
361
374
|
kwargs["stop"] = list(request.stop)
|
|
362
375
|
kwargs["stream"] = True
|
|
376
|
+
deadline = Deadline(provider.timeout)
|
|
363
377
|
try:
|
|
364
378
|
for chunk in client.completions.create(**kwargs):
|
|
379
|
+
deadline.check()
|
|
365
380
|
t = chunk.choices[0].text if chunk.choices else ""
|
|
366
381
|
if t:
|
|
367
382
|
sink.write(t)
|
|
@@ -45,6 +45,14 @@ def _add_common(s: argparse.ArgumentParser) -> None:
|
|
|
45
45
|
"escape for a literal $). Use for gateway routing keys the SDK won't "
|
|
46
46
|
"set (e.g. --header x-opencode-request=zsh-ai for OpenCode Zen).",
|
|
47
47
|
)
|
|
48
|
+
s.add_argument(
|
|
49
|
+
"--timeout",
|
|
50
|
+
type=float,
|
|
51
|
+
default=None,
|
|
52
|
+
metavar="SECONDS",
|
|
53
|
+
help="bound the whole call: connect, each read, and a total deadline "
|
|
54
|
+
"across the stream (default: the SDK's own, 600 s per read)",
|
|
55
|
+
)
|
|
48
56
|
s.add_argument(
|
|
49
57
|
"--adapter",
|
|
50
58
|
choices=["openai-compatible", "claude_code", "anthropic", "google"],
|
|
@@ -85,6 +93,7 @@ def _provider_from_args(args: argparse.Namespace) -> Provider:
|
|
|
85
93
|
max_tokens=args.max_tokens,
|
|
86
94
|
temperature=args.temperature,
|
|
87
95
|
headers=_parse_headers(args.header),
|
|
96
|
+
timeout=args.timeout,
|
|
88
97
|
)
|
|
89
98
|
|
|
90
99
|
|
|
@@ -64,9 +64,33 @@ def build_client(provider: Provider) -> Any:
|
|
|
64
64
|
base_url=provider.endpoint,
|
|
65
65
|
api_key=_resolve_api_key(provider),
|
|
66
66
|
default_headers=resolved_headers(provider),
|
|
67
|
+
**timeout_kwargs(provider),
|
|
67
68
|
)
|
|
68
69
|
|
|
69
70
|
|
|
71
|
+
def timeout_kwargs(provider: Provider) -> Dict[str, Any]:
|
|
72
|
+
"""Client kwargs for the openai/anthropic SDKs: ``timeout`` (connect and
|
|
73
|
+
each socket read) and no SDK retries, so a retry can't silently restart
|
|
74
|
+
the clock past the caller's budget. Empty when no timeout is set."""
|
|
75
|
+
if provider.timeout is None:
|
|
76
|
+
return {}
|
|
77
|
+
return {"timeout": provider.timeout, "max_retries": 0}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Deadline:
|
|
81
|
+
"""A total wall-clock budget for one call, checked between stream chunks.
|
|
82
|
+
The SDK's per-read timeout catches a stalled read; this catches a stream
|
|
83
|
+
that keeps trickling past the budget. ``None`` never expires."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, seconds: Optional[float]) -> None:
|
|
86
|
+
self._seconds = seconds
|
|
87
|
+
self._end = None if seconds is None else time.monotonic() + seconds
|
|
88
|
+
|
|
89
|
+
def check(self) -> None:
|
|
90
|
+
if self._end is not None and time.monotonic() > self._end:
|
|
91
|
+
raise TimeoutError(f"no complete response within {self._seconds:g}s")
|
|
92
|
+
|
|
93
|
+
|
|
70
94
|
def _resolve_api_key(provider: Provider) -> str:
|
|
71
95
|
if provider.api_key_env:
|
|
72
96
|
return os.environ.get(provider.api_key_env, "") or "placeholder"
|
|
@@ -51,6 +51,7 @@ _PROVIDER_FIELDS = frozenset(
|
|
|
51
51
|
"enable_thinking",
|
|
52
52
|
"stop",
|
|
53
53
|
"headers",
|
|
54
|
+
"timeout",
|
|
54
55
|
}
|
|
55
56
|
)
|
|
56
57
|
|
|
@@ -140,6 +141,13 @@ class Provider:
|
|
|
140
141
|
# OpenCode Zen's ``x-opencode-request``, which its qwen pool requires (absent
|
|
141
142
|
# it, zen 503s ``failover_exhausted``). Merges per-key over ``[defaults]``.
|
|
142
143
|
headers: Mapping[str, str] = field(default_factory=dict)
|
|
144
|
+
# Seconds. Bounds one call end to end: the HTTP adapters pass it to the SDK
|
|
145
|
+
# client (connect, and each socket read — so a stalled stream raises) and
|
|
146
|
+
# also check it as a total deadline between stream chunks (so a slow
|
|
147
|
+
# trickle is bounded too); claude_code cancels the CLI run when it expires.
|
|
148
|
+
# A stalled read can overshoot the deadline by up to one more ``timeout``.
|
|
149
|
+
# ``None`` leaves each SDK's own default (openai/anthropic: 600 s per read).
|
|
150
|
+
timeout: Optional[float] = None
|
|
143
151
|
# Forward-compat passthrough for unknown [providers.*] keys.
|
|
144
152
|
extra: Mapping[str, "TomlValue"] = field(default_factory=dict)
|
|
145
153
|
|
|
@@ -160,6 +168,7 @@ class Provider:
|
|
|
160
168
|
enable_thinking=_normalise_thinking(raw.get("enable_thinking")),
|
|
161
169
|
stop=_as_stop(raw.get("stop")),
|
|
162
170
|
headers=_as_headers(raw.get("headers")),
|
|
171
|
+
timeout=_opt_float(raw.get("timeout")),
|
|
163
172
|
extra=cast("Mapping[str, TomlValue]", extra),
|
|
164
173
|
)
|
|
165
174
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Recover a schema-shaped object from a model's structured-output reply.
|
|
2
|
+
|
|
3
|
+
Adapters ask for structured output natively (``response_format`` for
|
|
4
|
+
openai-compatible, a forced tool for anthropic, ``output_format`` for
|
|
5
|
+
claude_code), but an openai-compatible server is free to ignore
|
|
6
|
+
``response_format``: e.g. GLM at ollama.com answers in ordinary content, with
|
|
7
|
+
the JSON fenced in ````` ```json ````` and missing the schema's wrapper object
|
|
8
|
+
(a bare ``[...]`` where the schema says ``{"symbols": [...]}``). So the reply
|
|
9
|
+
is parsed leniently here, then checked against the schema, and any failure
|
|
10
|
+
raises :class:`StructuredOutputError` with the reason. Callers must never
|
|
11
|
+
mistake "nothing usable came back" for success.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
_FENCE = re.compile(r"```[A-Za-z0-9_-]*[ \t]*\n(.*?)\n?```", re.DOTALL)
|
|
21
|
+
_TYPES: dict[str, tuple[type, ...]] = {
|
|
22
|
+
"object": (dict,),
|
|
23
|
+
"array": (list,),
|
|
24
|
+
"string": (str,),
|
|
25
|
+
"integer": (int,),
|
|
26
|
+
"number": (int, float),
|
|
27
|
+
"boolean": (bool,),
|
|
28
|
+
"null": (type(None),),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StructuredOutputError(ValueError):
|
|
33
|
+
"""The reply held no JSON matching the requested schema."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_json(text: str) -> Any:
|
|
37
|
+
"""The JSON value in ``text``: the whole text, else the first fenced code
|
|
38
|
+
block that parses, else the first ``{``/``[`` value found in prose."""
|
|
39
|
+
s = text.strip()
|
|
40
|
+
if not s:
|
|
41
|
+
raise StructuredOutputError("empty reply")
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(s)
|
|
44
|
+
except ValueError:
|
|
45
|
+
pass
|
|
46
|
+
for m in _FENCE.finditer(s):
|
|
47
|
+
try:
|
|
48
|
+
return json.loads(m.group(1))
|
|
49
|
+
except ValueError:
|
|
50
|
+
continue
|
|
51
|
+
dec = json.JSONDecoder()
|
|
52
|
+
for i, ch in enumerate(s):
|
|
53
|
+
if ch in "{[":
|
|
54
|
+
try:
|
|
55
|
+
return dec.raw_decode(s, i)[0]
|
|
56
|
+
except ValueError:
|
|
57
|
+
continue
|
|
58
|
+
raise StructuredOutputError(f"no JSON value in reply: {s[:120]!r}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sole_array_property(schema: dict[str, Any]) -> str | None:
|
|
62
|
+
"""The one array-typed property of an object schema, if it has exactly
|
|
63
|
+
one property (or exactly one required one) and that property is an array."""
|
|
64
|
+
props = schema.get("properties") or {}
|
|
65
|
+
required = schema.get("required") or []
|
|
66
|
+
names = list(props) if len(props) == 1 else list(required)
|
|
67
|
+
if len(names) == 1 and (props.get(names[0]) or {}).get("type") == "array":
|
|
68
|
+
return names[0]
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _check(value: Any, schema: dict[str, Any], path: str) -> None:
|
|
73
|
+
"""A JSON Schema subset: type, enum, required, properties, items. Enough to
|
|
74
|
+
tell a schema-shaped reply from a wrong one, without a jsonschema dep."""
|
|
75
|
+
t = schema.get("type")
|
|
76
|
+
types = [t] if isinstance(t, str) else (t or [])
|
|
77
|
+
if types:
|
|
78
|
+
ok = any(
|
|
79
|
+
isinstance(value, _TYPES.get(name, ()))
|
|
80
|
+
# bool is an int subclass; JSON keeps them apart.
|
|
81
|
+
and not (isinstance(value, bool) and name in ("integer", "number"))
|
|
82
|
+
for name in types
|
|
83
|
+
)
|
|
84
|
+
if not ok:
|
|
85
|
+
raise StructuredOutputError(
|
|
86
|
+
f"{path or 'reply'}: expected {'/'.join(types)}, "
|
|
87
|
+
f"got {type(value).__name__}"
|
|
88
|
+
)
|
|
89
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
90
|
+
raise StructuredOutputError(f"{path or 'reply'}: {value!r} not in enum")
|
|
91
|
+
if isinstance(value, dict):
|
|
92
|
+
for key in schema.get("required") or []:
|
|
93
|
+
if key not in value:
|
|
94
|
+
raise StructuredOutputError(f"{path or 'reply'}: missing {key!r}")
|
|
95
|
+
for key, sub in (schema.get("properties") or {}).items():
|
|
96
|
+
if key in value and isinstance(sub, dict):
|
|
97
|
+
_check(value[key], sub, f"{path}.{key}" if path else key)
|
|
98
|
+
if isinstance(value, list) and isinstance(schema.get("items"), dict):
|
|
99
|
+
for i, item in enumerate(value):
|
|
100
|
+
_check(item, schema["items"], f"{path}[{i}]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def conform(value: Any, schema: dict[str, Any] | None) -> Any:
|
|
104
|
+
"""``value`` checked against ``schema``. A bare array where the schema is an
|
|
105
|
+
object with a single array property is wrapped into that property first
|
|
106
|
+
(the shape models produce when they skip the wrapper). Raises
|
|
107
|
+
:class:`StructuredOutputError` if it still doesn't fit."""
|
|
108
|
+
if not schema:
|
|
109
|
+
return value
|
|
110
|
+
if isinstance(value, list) and schema.get("type") == "object":
|
|
111
|
+
key = _sole_array_property(schema)
|
|
112
|
+
if key is not None:
|
|
113
|
+
value = {key: value}
|
|
114
|
+
_check(value, schema, "")
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_structured(text: str, schema: dict[str, Any] | None) -> Any:
|
|
119
|
+
"""Parse a structured-output reply and check it against ``schema``."""
|
|
120
|
+
return conform(extract_json(text), schema)
|
|
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
|