polyllmkit 0.1.2__tar.gz → 0.2.0__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 (28) hide show
  1. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/PKG-INFO +1 -1
  2. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/pyproject.toml +6 -1
  3. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/pyproject.toml.orig +8 -1
  4. polyllmkit-0.2.0/src/llmkit/bridge/_spawn.py +178 -0
  5. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/adapters/anthropic.py +4 -1
  6. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/adapters/claude_code.py +20 -4
  7. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/adapters/google.py +5 -1
  8. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/adapters/openai.py +3 -1
  9. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/bridge.py +4 -1
  10. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/cli.py +9 -0
  11. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/client.py +24 -0
  12. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/config.py +9 -0
  13. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/__init__.py +0 -0
  14. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/__init__.py +0 -0
  15. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/__main__.py +0 -0
  16. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/adapters/__init__.py +0 -0
  17. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/sinks.py +0 -0
  18. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/bridge/stream.py +0 -0
  19. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/md/__init__.py +0 -0
  20. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/md/render/__init__.py +0 -0
  21. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/md/render/__main__.py +0 -0
  22. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/md/render/cli.py +0 -0
  23. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/md/render/stream.py +0 -0
  24. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/md/view/__init__.py +0 -0
  25. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/md/view/__main__.py +0 -0
  26. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/md/view/app.py +0 -0
  27. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/src/llmkit/md/view/cli.py +0 -0
  28. {polyllmkit-0.1.2 → polyllmkit-0.2.0}/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.0
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.0"
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.0"
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
@@ -169,10 +182,13 @@ def structured_claude_code(
169
182
 
170
183
  async def _run_structured() -> None:
171
184
  options = _build_options(sdk, provider, request)
172
- async for message in sdk.query(prompt=request.user, options=options):
185
+ transport = cli_transport(request.user, options)
186
+ async for message in sdk.query(
187
+ prompt=request.user, options=options, transport=transport
188
+ ):
173
189
  if isinstance(message, sdk.ResultMessage):
174
190
  out["data"] = getattr(message, "structured_output", None)
175
191
  out["error"] = bool(getattr(message, "is_error", False))
176
192
 
177
- anyio.run(_run_structured)
193
+ anyio.run(_bounded, provider.timeout, _run_structured())
178
194
  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
@@ -22,7 +22,7 @@ import sys
22
22
  from dataclasses import dataclass
23
23
  from typing import Any, Callable, Optional, TextIO
24
24
 
25
- from .client import build_client
25
+ from .client import Deadline, build_client
26
26
  from .config import Config, Provider
27
27
  from .sinks import close_sink, open_target
28
28
  from .stream import StreamSplitter
@@ -119,6 +119,7 @@ def with_defaults(provider: Provider) -> Provider:
119
119
  enable_thinking=provider.enable_thinking or "auto",
120
120
  stop=provider.stop,
121
121
  headers=provider.headers,
122
+ timeout=provider.timeout,
122
123
  extra=provider.extra,
123
124
  )
124
125
 
@@ -360,8 +361,10 @@ def complete(
360
361
  if request.stop:
361
362
  kwargs["stop"] = list(request.stop)
362
363
  kwargs["stream"] = True
364
+ deadline = Deadline(provider.timeout)
363
365
  try:
364
366
  for chunk in client.completions.create(**kwargs):
367
+ deadline.check()
365
368
  t = chunk.choices[0].text if chunk.choices else ""
366
369
  if t:
367
370
  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