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.
Files changed (29) hide show
  1. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/PKG-INFO +1 -1
  2. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/pyproject.toml +6 -1
  3. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/pyproject.toml.orig +8 -1
  4. polyllmkit-0.2.1/src/llmkit/bridge/_spawn.py +178 -0
  5. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/anthropic.py +4 -1
  6. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/claude_code.py +24 -7
  7. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/google.py +5 -1
  8. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/openai.py +3 -1
  9. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/bridge.py +39 -24
  10. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/cli.py +9 -0
  11. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/client.py +24 -0
  12. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/config.py +9 -0
  13. polyllmkit-0.2.1/src/llmkit/bridge/structured.py +120 -0
  14. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/__init__.py +0 -0
  15. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/__init__.py +0 -0
  16. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/__main__.py +0 -0
  17. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/__init__.py +0 -0
  18. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/sinks.py +0 -0
  19. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/bridge/stream.py +0 -0
  20. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/__init__.py +0 -0
  21. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/render/__init__.py +0 -0
  22. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/render/__main__.py +0 -0
  23. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/render/cli.py +0 -0
  24. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/render/stream.py +0 -0
  25. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/view/__init__.py +0 -0
  26. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/view/__main__.py +0 -0
  27. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/view/app.py +0 -0
  28. {polyllmkit-0.1.2 → polyllmkit-0.2.1}/src/llmkit/md/view/cli.py +0 -0
  29. {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.2
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.2"
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.2"
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(_run, sdk, provider, request, emitter)
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
- async for message in sdk.query(prompt=request.user, options=options):
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, or ``(0, None)`` if the model produced
163
- none, or ``(1, None)`` on an SDK-reported error. No text parsing: the CLI does
164
- the schema enforcement + JSON parse and hands back a Python object."""
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
- async for message in sdk.query(prompt=request.user, options=options):
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, Callable, Optional, TextIO
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: Optional[str]) -> bool:
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: Optional[str] = None
88
- enable_thinking: Optional[str] = None
89
- schema: Optional[dict] = None
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) -> Optional[TextIO]:
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: Optional[TextIO], event: str) -> None:
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: Optional[TextIO]) -> None:
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
- try:
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
- try:
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)``. Returns
312
- ``(code, None)`` on a non-zero exit or unparseable output — the caller
313
- decides whether to fall back. Thinking is off (forced tool-use excludes it)."""
314
- import json
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
- return structured_claude_code(with_defaults(provider), request)
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 code, json.loads(text)
327
- except (ValueError, TypeError):
328
- return code, None
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: Optional[TextIO] = None,
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)