polyllmkit 0.1.1__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.
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/PKG-INFO +1 -1
- polyllmkit-0.2.0/pyproject.toml +42 -0
- polyllmkit-0.1.1/pyproject.toml → polyllmkit-0.2.0/pyproject.toml.orig +8 -1
- polyllmkit-0.2.0/src/llmkit/bridge/_spawn.py +178 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/adapters/anthropic.py +4 -1
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/adapters/claude_code.py +41 -16
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/adapters/google.py +5 -1
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/adapters/openai.py +3 -1
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/bridge.py +13 -10
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/cli.py +9 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/client.py +24 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/config.py +9 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/__init__.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/__init__.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/__main__.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/adapters/__init__.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/sinks.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/bridge/stream.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/md/__init__.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/md/render/__init__.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/md/render/__main__.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/md/render/cli.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/md/render/stream.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/md/view/__init__.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/md/view/__main__.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/md/view/app.py +0 -0
- {polyllmkit-0.1.1 → polyllmkit-0.2.0}/src/llmkit/md/view/cli.py +0 -0
- {polyllmkit-0.1.1 → 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.
|
|
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'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "polyllmkit"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Streaming, multi-provider LLM bridge + markdown rendering toolkit for terminal apps."
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = []
|
|
7
|
+
|
|
8
|
+
[project.optional-dependencies]
|
|
9
|
+
bridge = ["openai>=1.0"]
|
|
10
|
+
claude = ["claude-agent-sdk>=0.2"]
|
|
11
|
+
anthropic = ["anthropic>=0.40"]
|
|
12
|
+
google = ["google-genai>=1.0"]
|
|
13
|
+
all = [
|
|
14
|
+
"openai>=1.0",
|
|
15
|
+
"claude-agent-sdk>=0.2",
|
|
16
|
+
"anthropic>=0.40",
|
|
17
|
+
"google-genai>=1.0",
|
|
18
|
+
]
|
|
19
|
+
md = [
|
|
20
|
+
"rich>=13.0",
|
|
21
|
+
"textual>=0.50",
|
|
22
|
+
"markdown-it-py>=3.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["uv_build>=0.5,<0.12"]
|
|
27
|
+
build-backend = "uv_build"
|
|
28
|
+
|
|
29
|
+
[tool.uv.build-backend]
|
|
30
|
+
module-root = "src"
|
|
31
|
+
module-name = "llmkit"
|
|
32
|
+
|
|
33
|
+
[tool.pyright]
|
|
34
|
+
venvPath = "."
|
|
35
|
+
venv = ".venv"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"ruff>=0.6",
|
|
40
|
+
"mypy>=1.10",
|
|
41
|
+
"pytest>=9.2.0",
|
|
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.
|
|
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
|
|
|
@@ -58,15 +59,15 @@ def _thinking_budget() -> int:
|
|
|
58
59
|
|
|
59
60
|
def _build_options(sdk: Any, provider: Provider, request: ChatRequest) -> Any:
|
|
60
61
|
"""Assemble ClaudeAgentOptions for a tool-free single-turn chat."""
|
|
61
|
-
opts: dict[str, Any] =
|
|
62
|
-
tools
|
|
63
|
-
allowed_tools
|
|
64
|
-
disallowed_tools
|
|
65
|
-
max_turns
|
|
66
|
-
permission_mode
|
|
67
|
-
include_partial_messages
|
|
68
|
-
setting_sources
|
|
69
|
-
|
|
62
|
+
opts: dict[str, Any] = {
|
|
63
|
+
"tools": [], # no built-in tools — plain chat, no file/shell access
|
|
64
|
+
"allowed_tools": [],
|
|
65
|
+
"disallowed_tools": [],
|
|
66
|
+
"max_turns": 1,
|
|
67
|
+
"permission_mode": "bypassPermissions",
|
|
68
|
+
"include_partial_messages": True, # token-level streaming deltas
|
|
69
|
+
"setting_sources": [], # ignore project/user CLAUDE.md, settings, etc.
|
|
70
|
+
}
|
|
70
71
|
if provider.model:
|
|
71
72
|
opts["model"] = provider.model
|
|
72
73
|
if request.system:
|
|
@@ -87,13 +88,22 @@ def _build_options(sdk: Any, provider: Provider, request: ChatRequest) -> Any:
|
|
|
87
88
|
opts["thinking"] = {"type": "disabled"}
|
|
88
89
|
return sdk.ClaudeAgentOptions(**opts)
|
|
89
90
|
|
|
91
|
+
# ``display: "summarized"`` is required on current models: they default it
|
|
92
|
+
# to ``omitted``, which silences thinking entirely — no deltas stream and
|
|
93
|
+
# the final ThinkingBlock is empty (verified: enabled+budget without display
|
|
94
|
+
# streams 0 chars of thinking; with display, ~tokens arrive). Mirrors the
|
|
95
|
+
# anthropic adapter's same-named field.
|
|
90
96
|
flag = provider.enable_thinking or "auto"
|
|
91
97
|
if flag == "false":
|
|
92
98
|
opts["thinking"] = {"type": "disabled"}
|
|
93
99
|
elif flag == "true":
|
|
94
|
-
opts["thinking"] = {
|
|
100
|
+
opts["thinking"] = {
|
|
101
|
+
"type": "enabled",
|
|
102
|
+
"budget_tokens": _thinking_budget(),
|
|
103
|
+
"display": "summarized",
|
|
104
|
+
}
|
|
95
105
|
else:
|
|
96
|
-
opts["thinking"] = {"type": "adaptive"}
|
|
106
|
+
opts["thinking"] = {"type": "adaptive", "display": "summarized"}
|
|
97
107
|
|
|
98
108
|
return sdk.ClaudeAgentOptions(**opts)
|
|
99
109
|
|
|
@@ -122,7 +132,16 @@ def stream_claude_code(
|
|
|
122
132
|
sdk = _require_sdk()
|
|
123
133
|
import anyio # bundled with the SDK
|
|
124
134
|
|
|
125
|
-
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
|
|
126
145
|
|
|
127
146
|
|
|
128
147
|
async def _run(
|
|
@@ -130,7 +149,10 @@ async def _run(
|
|
|
130
149
|
) -> None:
|
|
131
150
|
options = _build_options(sdk, provider, request)
|
|
132
151
|
saw_delta = False
|
|
133
|
-
|
|
152
|
+
transport = cli_transport(request.user, options)
|
|
153
|
+
async for message in sdk.query(
|
|
154
|
+
prompt=request.user, options=options, transport=transport
|
|
155
|
+
):
|
|
134
156
|
if isinstance(message, sdk.StreamEvent):
|
|
135
157
|
if _handle_event(message.event, emitter):
|
|
136
158
|
saw_delta = True
|
|
@@ -146,7 +168,7 @@ async def _run(
|
|
|
146
168
|
|
|
147
169
|
def structured_claude_code(
|
|
148
170
|
provider: Provider, request: ChatRequest
|
|
149
|
-
) ->
|
|
171
|
+
) -> tuple[int, Any]:
|
|
150
172
|
"""Structured output via the SDK's native ``output_format`` (set in
|
|
151
173
|
``_build_options`` when the request carries a schema). Runs the single-turn
|
|
152
174
|
query and returns ``(exit_code, structured_output)`` from the ResultMessage —
|
|
@@ -160,10 +182,13 @@ def structured_claude_code(
|
|
|
160
182
|
|
|
161
183
|
async def _run_structured() -> None:
|
|
162
184
|
options = _build_options(sdk, provider, request)
|
|
163
|
-
|
|
185
|
+
transport = cli_transport(request.user, options)
|
|
186
|
+
async for message in sdk.query(
|
|
187
|
+
prompt=request.user, options=options, transport=transport
|
|
188
|
+
):
|
|
164
189
|
if isinstance(message, sdk.ResultMessage):
|
|
165
190
|
out["data"] = getattr(message, "structured_output", None)
|
|
166
191
|
out["error"] = bool(getattr(message, "is_error", False))
|
|
167
192
|
|
|
168
|
-
anyio.run(_run_structured)
|
|
193
|
+
anyio.run(_bounded, provider.timeout, _run_structured())
|
|
169
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
|
|
|
@@ -336,7 +337,7 @@ def complete(
|
|
|
336
337
|
) -> int:
|
|
337
338
|
"""Run a text-completion (FIM) request, streaming text to ``out``
|
|
338
339
|
(default stdout). ``openai-compatible`` only."""
|
|
339
|
-
|
|
340
|
+
sink = out if out is not None else sys.stdout
|
|
340
341
|
provider = with_defaults(provider)
|
|
341
342
|
if not provider_supports_complete(provider):
|
|
342
343
|
print(
|
|
@@ -349,23 +350,25 @@ def complete(
|
|
|
349
350
|
return 2
|
|
350
351
|
|
|
351
352
|
client = build_client(provider)
|
|
352
|
-
kwargs: dict[str, Any] =
|
|
353
|
-
model
|
|
354
|
-
prompt
|
|
355
|
-
max_tokens
|
|
356
|
-
temperature
|
|
357
|
-
|
|
353
|
+
kwargs: dict[str, Any] = {
|
|
354
|
+
"model": provider.model,
|
|
355
|
+
"prompt": request.prompt,
|
|
356
|
+
"max_tokens": provider.max_tokens,
|
|
357
|
+
"temperature": provider.temperature,
|
|
358
|
+
}
|
|
358
359
|
if request.suffix:
|
|
359
360
|
kwargs["suffix"] = request.suffix
|
|
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
|
-
|
|
368
|
-
|
|
370
|
+
sink.write(t)
|
|
371
|
+
sink.flush()
|
|
369
372
|
except KeyboardInterrupt:
|
|
370
373
|
return 130
|
|
371
374
|
except Exception as e:
|
|
@@ -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
|
|
|
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
|