python-agent-harness 1.5.0__py3-none-any.whl
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.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
"""OpenAI-compatible chat client built on httpx.
|
|
2
|
+
|
|
3
|
+
Supports both streaming (default) and non-streaming requests against
|
|
4
|
+
any backend speaking the chat-completions protocol (OpenAI, DeepSeek,
|
|
5
|
+
Moonshot/Kimi, GLM/Zhipu, Qwen/DashScope, ...).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import random
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable, Iterator
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from . import config
|
|
24
|
+
from .models import Message, ToolCall, ToolSpec, Usage
|
|
25
|
+
|
|
26
|
+
# serializes appends to the shared LLM log file: concurrent sub-agents
|
|
27
|
+
# (each with its own client but ONE shared log_path, see Client.clone)
|
|
28
|
+
# finish their interactions in parallel, and interleaved write() calls
|
|
29
|
+
# would corrupt the JSON stream
|
|
30
|
+
_log_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApiError(Exception):
|
|
34
|
+
"""Raised when the API call itself fails (network/HTTP)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RetryableApiError(ApiError):
|
|
38
|
+
"""A transient failure (rate limit, server error) safe to retry.
|
|
39
|
+
|
|
40
|
+
Carries the server's ``Retry-After`` value (if any) so the retry
|
|
41
|
+
backoff can honor it. Permanent errors remain a plain ApiError.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str, retry_after: str | None = None) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.retry_after = retry_after
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AuthExpiredError(ApiError):
|
|
50
|
+
"""Raised on HTTP 401 — the credential may have expired.
|
|
51
|
+
|
|
52
|
+
Handled specially in the retry loop: the API key is re-read from
|
|
53
|
+
config/environment (an external process may have refreshed the
|
|
54
|
+
token) and the request is retried once with the new key. If the
|
|
55
|
+
key hasn't changed, the error is permanent and propagated as a
|
|
56
|
+
plain ApiError.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _retryable_status(status: int) -> bool:
|
|
61
|
+
"""429 and 5xx are transient; every other error is permanent."""
|
|
62
|
+
return status == 429 or status >= 500
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _error_text(data: dict[str, Any]) -> str | None:
|
|
66
|
+
"""A readable message from an API error body, if any.
|
|
67
|
+
|
|
68
|
+
Some backends return ``{"error": "message"}`` or
|
|
69
|
+
``{"error": {"message": "..."}}`` as the JSON payload (or as a
|
|
70
|
+
mid-stream chunk); ``None`` when the payload carries no error.
|
|
71
|
+
"""
|
|
72
|
+
err = data.get("error")
|
|
73
|
+
if not err:
|
|
74
|
+
return None
|
|
75
|
+
if isinstance(err, dict):
|
|
76
|
+
return str(err.get("message") or err)
|
|
77
|
+
return str(err)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _retry_delay(
|
|
81
|
+
attempt: int,
|
|
82
|
+
retry_after: str | None,
|
|
83
|
+
base_delay: float,
|
|
84
|
+
max_delay: float,
|
|
85
|
+
) -> float:
|
|
86
|
+
"""Backoff delay for the failed ATTEMPT (1 = first attempt).
|
|
87
|
+
|
|
88
|
+
Computed as ``base_delay`` doubled per attempt, capped at
|
|
89
|
+
``max_delay``, plus jitter. A ``Retry-After`` header (seconds)
|
|
90
|
+
from a 429 response wins when present.
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(retry_after, str) and retry_after.strip():
|
|
93
|
+
try:
|
|
94
|
+
secs = float(retry_after.strip())
|
|
95
|
+
except ValueError:
|
|
96
|
+
pass
|
|
97
|
+
else:
|
|
98
|
+
return min(max(0, secs), max_delay) + random.uniform(0, 0.5)
|
|
99
|
+
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
|
100
|
+
return delay + random.uniform(0, delay * 0.3)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _llm_log_path() -> Path:
|
|
104
|
+
"""Return the LLM log file path for a new session."""
|
|
105
|
+
date_str = time.strftime("%Y%m%d")
|
|
106
|
+
session_id = uuid.uuid4().hex[:8]
|
|
107
|
+
log_dir = os.environ.get("LLM_LOG_DIR")
|
|
108
|
+
if log_dir:
|
|
109
|
+
d = Path(log_dir)
|
|
110
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
return d / f"python-agent-harness-{date_str}-{session_id}.json"
|
|
112
|
+
return Path(f"/tmp/python-agent-harness-{date_str}-{session_id}.json")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _log_llm_interaction(
|
|
116
|
+
log_file: Path | None, payload: dict[str, Any], response_msg: Message, usage: Usage
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Append an LLM interaction to the log file as pretty-printed JSON."""
|
|
119
|
+
if not log_file:
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
# Build the entry in the same format as the conversation:
|
|
123
|
+
# { "model": ..., "messages": [...all messages including response...] }
|
|
124
|
+
messages = list(payload.get("messages", []))
|
|
125
|
+
|
|
126
|
+
# Append the assistant response
|
|
127
|
+
resp: dict[str, Any] = {"role": "assistant"}
|
|
128
|
+
if response_msg.text():
|
|
129
|
+
resp["content"] = response_msg.text()
|
|
130
|
+
if response_msg.tool_calls:
|
|
131
|
+
resp["tool_calls"] = [
|
|
132
|
+
{
|
|
133
|
+
"type": "function",
|
|
134
|
+
"id": tc.id,
|
|
135
|
+
"function": {
|
|
136
|
+
"name": tc.name,
|
|
137
|
+
"arguments": tc.arguments
|
|
138
|
+
if isinstance(tc.arguments, str)
|
|
139
|
+
else json.dumps(tc.arguments, ensure_ascii=False),
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
for tc in response_msg.tool_calls
|
|
143
|
+
]
|
|
144
|
+
messages.append(resp)
|
|
145
|
+
|
|
146
|
+
body: dict[str, Any] = {
|
|
147
|
+
"model": payload.get("model", ""),
|
|
148
|
+
"messages": messages,
|
|
149
|
+
}
|
|
150
|
+
if payload.get("tools"):
|
|
151
|
+
body["tools"] = payload["tools"]
|
|
152
|
+
|
|
153
|
+
marker: dict[str, Any] = {
|
|
154
|
+
"python-agent-harness": "request body",
|
|
155
|
+
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
with _log_lock, open(log_file, "a", encoding="utf-8") as f:
|
|
159
|
+
f.write(json.dumps(marker, indent=2, ensure_ascii=False) + "\n")
|
|
160
|
+
f.write(json.dumps(body, indent=2, ensure_ascii=False) + "\n")
|
|
161
|
+
except Exception: # noqa: BLE001 - logging must never break the agent
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resolve_ca_bundle() -> str | bool:
|
|
166
|
+
"""Return a CA bundle path usable for TLS verification, or True (default).
|
|
167
|
+
|
|
168
|
+
Python's bundled cert.pem often lacks the internal CA chain,
|
|
169
|
+
so prefer a system CA bundle when one exists. An explicit
|
|
170
|
+
SSL_CERT_FILE env var wins; otherwise fall back to common system
|
|
171
|
+
bundle locations before letting httpx use its default.
|
|
172
|
+
"""
|
|
173
|
+
env = os.environ.get("SSL_CERT_FILE")
|
|
174
|
+
if env and os.path.isfile(env):
|
|
175
|
+
return env
|
|
176
|
+
for cand in (
|
|
177
|
+
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
178
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
179
|
+
"/etc/ssl/ca-bundle.pem",
|
|
180
|
+
):
|
|
181
|
+
if os.path.isfile(cand):
|
|
182
|
+
return cand
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class Client:
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
base_url: str | None = None,
|
|
190
|
+
api_key: str | None = None,
|
|
191
|
+
model: str | None = None,
|
|
192
|
+
timeout: float = 600.0,
|
|
193
|
+
verify: str | bool | None = None,
|
|
194
|
+
retry_max: int | None = None,
|
|
195
|
+
retry_base_delay: float | None = None,
|
|
196
|
+
retry_max_delay: float | None = None,
|
|
197
|
+
config_path: str | None = None,
|
|
198
|
+
log_path: Path | None = None,
|
|
199
|
+
) -> None:
|
|
200
|
+
self.base_url = (base_url or config.DEFAULT_BASE_URL).rstrip("/")
|
|
201
|
+
self.api_key = api_key or _default_api_key()
|
|
202
|
+
self.model = model or config.DEFAULT_MODEL
|
|
203
|
+
self.timeout = timeout
|
|
204
|
+
self.verify = verify if verify is not None else _resolve_ca_bundle()
|
|
205
|
+
self.retry_max = config.API_RETRY_MAX if retry_max is None else retry_max
|
|
206
|
+
self.retry_base_delay = (
|
|
207
|
+
config.API_RETRY_BASE_DELAY if retry_base_delay is None else retry_base_delay
|
|
208
|
+
)
|
|
209
|
+
self.retry_max_delay = (
|
|
210
|
+
config.API_RETRY_MAX_DELAY if retry_max_delay is None else retry_max_delay
|
|
211
|
+
)
|
|
212
|
+
self._config_path = config_path
|
|
213
|
+
self._http = httpx.Client(timeout=timeout, verify=self.verify)
|
|
214
|
+
# True while the in-flight request was aborted (Ctrl-C): a
|
|
215
|
+
# connection error on an aborted request must NOT be retried —
|
|
216
|
+
# the user asked to stop. Cleared at the start of each chat()
|
|
217
|
+
# so a fresh turn may retry normally.
|
|
218
|
+
self._aborted = False
|
|
219
|
+
# an explicit log file is inherited by clones so every request
|
|
220
|
+
# of one session (main + all sub-agents) lands in a single log
|
|
221
|
+
self.log_path = (
|
|
222
|
+
log_path
|
|
223
|
+
if log_path is not None
|
|
224
|
+
else (_llm_log_path() if config.LLM_LOG_ENABLED else None)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def close(self) -> None:
|
|
228
|
+
self._http.close()
|
|
229
|
+
|
|
230
|
+
def clone(self) -> Client:
|
|
231
|
+
"""A fresh Client with identical settings (no shared state).
|
|
232
|
+
|
|
233
|
+
Concurrent requests must never share one Client: ``_reset_http``
|
|
234
|
+
and ``abort`` swap and close the underlying httpx pool, and
|
|
235
|
+
``_aborted`` is per-request flag state — so one request's
|
|
236
|
+
connection failure (or Ctrl-C abort) would tear down a
|
|
237
|
+
sibling's in-flight request on the same client. Each
|
|
238
|
+
concurrent sub-agent clones its own client (see
|
|
239
|
+
``Session.run_subagent``), keeping pools and the abort
|
|
240
|
+
flag strictly per-request. The log file is shared so one
|
|
241
|
+
session's LLM interactions stay in one log.
|
|
242
|
+
"""
|
|
243
|
+
return Client(
|
|
244
|
+
base_url=self.base_url,
|
|
245
|
+
api_key=self.api_key,
|
|
246
|
+
model=self.model,
|
|
247
|
+
timeout=self.timeout,
|
|
248
|
+
verify=self.verify,
|
|
249
|
+
retry_max=self.retry_max,
|
|
250
|
+
retry_base_delay=self.retry_base_delay,
|
|
251
|
+
retry_max_delay=self.retry_max_delay,
|
|
252
|
+
config_path=self._config_path,
|
|
253
|
+
log_path=self.log_path,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def abort(self) -> None:
|
|
257
|
+
"""Abort the in-flight request (called on cancel).
|
|
258
|
+
|
|
259
|
+
A blocked ``iter_lines()`` read must be interrupted so the agent
|
|
260
|
+
loop can stop promptly. Closing the pool alone is NOT enough:
|
|
261
|
+
on Linux, ``close()`` from another thread cannot wake a ``recv``
|
|
262
|
+
that is already blocked in the kernel. So we first
|
|
263
|
+
``shutdown(SHUT_RDWR)`` every in-flight connection socket (which
|
|
264
|
+
does wake the blocked read, turning it into a connection error
|
|
265
|
+
the loop treats as a cancel) and then close the pool. A fresh
|
|
266
|
+
client is swapped in for the next request.
|
|
267
|
+
"""
|
|
268
|
+
self._aborted = True
|
|
269
|
+
old = self._http
|
|
270
|
+
self._http = httpx.Client(timeout=self.timeout, verify=self.verify)
|
|
271
|
+
with contextlib.suppress(Exception): # best effort
|
|
272
|
+
_abort_inflight_sockets(old)
|
|
273
|
+
with contextlib.suppress(Exception): # best effort
|
|
274
|
+
old.close()
|
|
275
|
+
|
|
276
|
+
def _reset_http(self) -> None:
|
|
277
|
+
"""Replace the httpx client with a fresh instance.
|
|
278
|
+
|
|
279
|
+
Called before every connection-error retry (and on exhaustion)
|
|
280
|
+
so a poisoned pool (stale/dead connections) never dooms the
|
|
281
|
+
retry itself or every subsequent request in the session.
|
|
282
|
+
"""
|
|
283
|
+
old = self._http
|
|
284
|
+
self._http = httpx.Client(timeout=self.timeout, verify=self.verify)
|
|
285
|
+
with contextlib.suppress(Exception): # best effort
|
|
286
|
+
old.close()
|
|
287
|
+
|
|
288
|
+
def set_timeout(self, timeout: float) -> None:
|
|
289
|
+
"""Update the request timeout and recreate the HTTP pool.
|
|
290
|
+
|
|
291
|
+
The pool is created once in ``__init__`` with the initial
|
|
292
|
+
timeout, so changing the attribute alone would not affect
|
|
293
|
+
in-flight/future requests. Recreating the pool makes the new
|
|
294
|
+
timeout apply to subsequent requests immediately (used by
|
|
295
|
+
/model switching).
|
|
296
|
+
"""
|
|
297
|
+
self.timeout = timeout
|
|
298
|
+
self._reset_http()
|
|
299
|
+
|
|
300
|
+
def _refresh_api_key(
|
|
301
|
+
self,
|
|
302
|
+
cancel_check: Callable[[], bool] | None = None,
|
|
303
|
+
timeout: float = 30.0,
|
|
304
|
+
poll_interval: float = 2.0,
|
|
305
|
+
) -> bool:
|
|
306
|
+
"""Re-read the API key from the config file and environment.
|
|
307
|
+
|
|
308
|
+
Called on HTTP 401 (auth expired): an external process (e.g. a
|
|
309
|
+
token refresh script) may need time to write a new key. This
|
|
310
|
+
method polls the config file / environment every
|
|
311
|
+
``poll_interval`` seconds for up to ``timeout`` seconds, waiting
|
|
312
|
+
for the key to change. If a fresh key is found that differs
|
|
313
|
+
from the current one, update ``self.api_key`` and return True
|
|
314
|
+
(the caller should retry). If the key remains unchanged after
|
|
315
|
+
the timeout, return False (permanent auth failure).
|
|
316
|
+
|
|
317
|
+
``cancel_check`` is polled each iteration so Ctrl-C aborts the
|
|
318
|
+
wait promptly.
|
|
319
|
+
"""
|
|
320
|
+
deadline = time.monotonic() + timeout
|
|
321
|
+
while True:
|
|
322
|
+
try:
|
|
323
|
+
settings = config.load_llm_config(self._config_path)
|
|
324
|
+
new_key = settings.get("api_key") or _default_api_key()
|
|
325
|
+
except Exception: # noqa: BLE001 - config read must not crash
|
|
326
|
+
new_key = _default_api_key()
|
|
327
|
+
if new_key and new_key != self.api_key:
|
|
328
|
+
self.api_key = new_key
|
|
329
|
+
return True
|
|
330
|
+
remaining = deadline - time.monotonic()
|
|
331
|
+
if remaining <= 0:
|
|
332
|
+
return False
|
|
333
|
+
if cancel_check is not None and cancel_check():
|
|
334
|
+
return False
|
|
335
|
+
time.sleep(min(poll_interval, remaining))
|
|
336
|
+
|
|
337
|
+
# -- request plumbing -------------------------------------------------
|
|
338
|
+
def _headers(self, stream: bool = True) -> dict[str, str]:
|
|
339
|
+
h = {
|
|
340
|
+
"Content-Type": "application/json",
|
|
341
|
+
"Accept": "text/event-stream" if stream else "application/json",
|
|
342
|
+
}
|
|
343
|
+
if self.api_key:
|
|
344
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
345
|
+
return h
|
|
346
|
+
|
|
347
|
+
def _url(self) -> str:
|
|
348
|
+
return f"{self.base_url}/chat/completions"
|
|
349
|
+
|
|
350
|
+
def _payload(
|
|
351
|
+
self,
|
|
352
|
+
messages: list[Message],
|
|
353
|
+
tools: list[ToolSpec] | None = None,
|
|
354
|
+
stream: bool = True,
|
|
355
|
+
temperature: float | None = None,
|
|
356
|
+
max_tokens: int | None = None,
|
|
357
|
+
system: str | None = None,
|
|
358
|
+
reasoning_effort: str | None = None,
|
|
359
|
+
) -> dict[str, Any]:
|
|
360
|
+
msgs = [m.to_api() for m in messages]
|
|
361
|
+
if system:
|
|
362
|
+
msgs = [{"role": "system", "content": system}] + msgs
|
|
363
|
+
payload: dict[str, Any] = {
|
|
364
|
+
"model": self.model,
|
|
365
|
+
"messages": msgs,
|
|
366
|
+
"stream": stream,
|
|
367
|
+
}
|
|
368
|
+
if stream:
|
|
369
|
+
payload["stream_options"] = {"include_usage": True}
|
|
370
|
+
if tools:
|
|
371
|
+
payload["tools"] = [t.to_api() for t in tools]
|
|
372
|
+
if temperature is not None:
|
|
373
|
+
payload["temperature"] = temperature
|
|
374
|
+
if max_tokens is not None:
|
|
375
|
+
payload["max_tokens"] = max_tokens
|
|
376
|
+
if reasoning_effort is not None:
|
|
377
|
+
payload["reasoning_effort"] = reasoning_effort
|
|
378
|
+
return payload
|
|
379
|
+
|
|
380
|
+
# -- chat --------------------------------------------------------------
|
|
381
|
+
def chat(
|
|
382
|
+
self,
|
|
383
|
+
messages: list[Message],
|
|
384
|
+
tools: list[ToolSpec] | None = None,
|
|
385
|
+
system: str | None = None,
|
|
386
|
+
temperature: float | None = None,
|
|
387
|
+
max_tokens: int | None = None,
|
|
388
|
+
reasoning_effort: str | None = None,
|
|
389
|
+
on_delta: Callable[[str], None] | None = None,
|
|
390
|
+
on_tool_call: Callable[[str, str, str], None] | None = None,
|
|
391
|
+
stream: bool = True,
|
|
392
|
+
cancel_check: Callable[[], bool] | None = None,
|
|
393
|
+
on_retry: Callable[[], None] | None = None,
|
|
394
|
+
) -> tuple[Message, Usage]:
|
|
395
|
+
"""Send a chat request, return (assistant msg, usage).
|
|
396
|
+
|
|
397
|
+
With ``stream`` True (default) the response is streamed: on_delta
|
|
398
|
+
is invoked with each text chunk as it arrives and on_tool_call
|
|
399
|
+
(name, id, json_fragment) with each tool-call fragment. With
|
|
400
|
+
``stream`` False a single non-streaming POST is used; both
|
|
401
|
+
callbacks fire once per text/tool-call with the complete values,
|
|
402
|
+
so callers (agent loop, TUI) behave identically either way.
|
|
403
|
+
|
|
404
|
+
Transient failures (HTTP 429 / 5xx, connection errors) are
|
|
405
|
+
retried with exponential backoff + jitter up to ``retry_max``
|
|
406
|
+
attempts, honoring ``Retry-After`` when present. A connection
|
|
407
|
+
error always swaps in a fresh httpx client first (``_reset_http``)
|
|
408
|
+
so a dead connection never poisons the retry — and a stream that
|
|
409
|
+
died mid-body IS retried even when deltas already reached the
|
|
410
|
+
callers: the partial stream is discarded on retry (``on_retry``
|
|
411
|
+
lets the caller drop its live text), so nothing is duplicated in
|
|
412
|
+
the returned message. Other 4xx errors are permanent and fail
|
|
413
|
+
immediately. ``cancel_check`` (when given) is polled during
|
|
414
|
+
backoff sleeps so an abort lands promptly instead of after the
|
|
415
|
+
full wait. ``on_retry`` (when given) is invoked right before a
|
|
416
|
+
retry after a connection error, so a UI can clear the partial
|
|
417
|
+
output and show that the request is being restarted.
|
|
418
|
+
"""
|
|
419
|
+
payload = self._payload(
|
|
420
|
+
messages,
|
|
421
|
+
tools,
|
|
422
|
+
stream=stream,
|
|
423
|
+
temperature=temperature,
|
|
424
|
+
max_tokens=max_tokens,
|
|
425
|
+
system=system,
|
|
426
|
+
reasoning_effort=reasoning_effort,
|
|
427
|
+
)
|
|
428
|
+
# a fresh turn may retry connection errors even if a previous
|
|
429
|
+
# in-flight request was aborted (see abort/_aborted)
|
|
430
|
+
self._aborted = False
|
|
431
|
+
usage = Usage()
|
|
432
|
+
emitted = False
|
|
433
|
+
|
|
434
|
+
def wrap_delta(chunk: str) -> None:
|
|
435
|
+
nonlocal emitted
|
|
436
|
+
emitted = True
|
|
437
|
+
if on_delta:
|
|
438
|
+
# a presentational sink (live UI) — its failure (e.g. a
|
|
439
|
+
# BrokenPipeError on a closed terminal) must never reach
|
|
440
|
+
# the retry loop below, or it would be mistaken for a
|
|
441
|
+
# transport error and pointlessly re-send the request
|
|
442
|
+
with contextlib.suppress(Exception): # streaming UI is best effort
|
|
443
|
+
on_delta(chunk)
|
|
444
|
+
|
|
445
|
+
def wrap_tool_call(name: str, call_id: str, fragment: str) -> None:
|
|
446
|
+
nonlocal emitted
|
|
447
|
+
emitted = True
|
|
448
|
+
if on_tool_call:
|
|
449
|
+
with contextlib.suppress(Exception): # streaming UI is best effort
|
|
450
|
+
on_tool_call(name, call_id, fragment)
|
|
451
|
+
|
|
452
|
+
attempt = 0
|
|
453
|
+
auth_refreshed = False
|
|
454
|
+
while True:
|
|
455
|
+
attempt += 1
|
|
456
|
+
# Track emission per-attempt. Whether a PRIOR attempt
|
|
457
|
+
# streamed partial text is irrelevant to retrying this one:
|
|
458
|
+
# that partial was already cleared (via on_retry) when the
|
|
459
|
+
# prior attempt failed. Resetting here lets a transient
|
|
460
|
+
# status (429/5xx) arriving after a dropped partial stream
|
|
461
|
+
# still be retried, consistent with the connection-error
|
|
462
|
+
# branch below.
|
|
463
|
+
emitted = False
|
|
464
|
+
try:
|
|
465
|
+
if stream:
|
|
466
|
+
content_parts, reasoning_parts, tc_index = self._stream_response(
|
|
467
|
+
payload, wrap_delta, wrap_tool_call, usage
|
|
468
|
+
)
|
|
469
|
+
else:
|
|
470
|
+
content_parts, reasoning_parts, tc_index = self._sync_response(
|
|
471
|
+
payload, wrap_delta, wrap_tool_call, usage
|
|
472
|
+
)
|
|
473
|
+
break
|
|
474
|
+
except RetryableApiError as e:
|
|
475
|
+
# transient status (429 / 5xx): retry with backoff until
|
|
476
|
+
# the attempt budget is exhausted. If this attempt had
|
|
477
|
+
# already streamed partial text to the caller, clear it
|
|
478
|
+
# first (mirrors the connection-error branch) so the
|
|
479
|
+
# retry never duplicates output.
|
|
480
|
+
if attempt >= self.retry_max:
|
|
481
|
+
raise
|
|
482
|
+
if emitted and on_retry is not None:
|
|
483
|
+
on_retry()
|
|
484
|
+
if self._sleep_backoff(attempt, e.retry_after, cancel_check):
|
|
485
|
+
raise
|
|
486
|
+
except AuthExpiredError as e:
|
|
487
|
+
# HTTP 401: the credential (often a JWT with a short
|
|
488
|
+
# TTL) may have expired. Re-read the API key from the
|
|
489
|
+
# config file / environment — an external token-refresh
|
|
490
|
+
# process may have written a new one. Poll for up to
|
|
491
|
+
# 30s waiting for the key to change; retry once if it
|
|
492
|
+
# does. Fail immediately if already refreshed once
|
|
493
|
+
# (prevents infinite loops).
|
|
494
|
+
self._reset_http()
|
|
495
|
+
if auth_refreshed or not self._refresh_api_key(cancel_check):
|
|
496
|
+
raise ApiError(str(e)) from e
|
|
497
|
+
auth_refreshed = True
|
|
498
|
+
# Key refreshed — retry immediately (no backoff needed,
|
|
499
|
+
# and only one extra attempt regardless of retry_max)
|
|
500
|
+
if emitted and on_retry is not None:
|
|
501
|
+
on_retry()
|
|
502
|
+
continue
|
|
503
|
+
except (httpx.HTTPError, OSError) as e:
|
|
504
|
+
# connection-level failures: connect errors, timeouts,
|
|
505
|
+
# dropped streams. ``httpx.HTTPError`` covers everything
|
|
506
|
+
# httpx wraps, but a raw ``OSError`` (ConnectionResetError,
|
|
507
|
+
# BrokenPipeError, ``ssl.SSLError`` — all OSError
|
|
508
|
+
# subclasses) can still leak from the socket layer,
|
|
509
|
+
# notably out of the streaming generator or during SSL
|
|
510
|
+
# teardown. Such an error MUST be treated the same way:
|
|
511
|
+
# if it escaped uncaught the poisoned connection would
|
|
512
|
+
# stay in the pool and doom every following request in
|
|
513
|
+
# the session (the reported "connection broken -> all
|
|
514
|
+
# later requests fail" symptom). Swap in a fresh client
|
|
515
|
+
# immediately — a dead connection must not stay in the
|
|
516
|
+
# pool for the retry — then retry the request, even when
|
|
517
|
+
# deltas already reached the caller: the partial stream
|
|
518
|
+
# is discarded on retry (on_retry lets the caller clear
|
|
519
|
+
# its live text), so nothing is duplicated in the stored
|
|
520
|
+
# message. Only give up once the per-request attempt
|
|
521
|
+
# budget is exhausted — or immediately when the request
|
|
522
|
+
# was aborted (Ctrl-C: the user asked to stop, so a
|
|
523
|
+
# fresh attempt must not be started).
|
|
524
|
+
self._reset_http()
|
|
525
|
+
if self._aborted or attempt >= self.retry_max:
|
|
526
|
+
raise ApiError(f"network error: {e}") from e
|
|
527
|
+
if on_retry is not None:
|
|
528
|
+
on_retry()
|
|
529
|
+
if self._sleep_backoff(attempt, None, cancel_check):
|
|
530
|
+
raise ApiError(f"network error: {e}") from e
|
|
531
|
+
except Exception:
|
|
532
|
+
# safety net for anything unexpected (permanent ApiError
|
|
533
|
+
# from a 4xx, a parsing bug, a raising on_delta callback,
|
|
534
|
+
# ...). We do NOT retry these — retrying a permanent
|
|
535
|
+
# error just burns the budget with backoff, and retrying
|
|
536
|
+
# a bug masks it as a bogus "network error". But the
|
|
537
|
+
# request may have died mid-stream, leaving the
|
|
538
|
+
# connection in an indeterminate state, so we still
|
|
539
|
+
# reset the pool before propagating: a poisoned
|
|
540
|
+
# connection must never survive into the next request,
|
|
541
|
+
# whatever the cause. (BaseException — KeyboardInterrupt
|
|
542
|
+
# / SystemExit — is intentionally not caught here.)
|
|
543
|
+
self._reset_http()
|
|
544
|
+
raise
|
|
545
|
+
|
|
546
|
+
content = "".join(content_parts)
|
|
547
|
+
tool_calls = None
|
|
548
|
+
if tc_index:
|
|
549
|
+
tool_calls = [
|
|
550
|
+
ToolCall(
|
|
551
|
+
id=tc_index[i]["id"] or f"call_{i}",
|
|
552
|
+
name=tc_index[i]["name"],
|
|
553
|
+
arguments=tc_index[i]["arguments"] or "{}",
|
|
554
|
+
)
|
|
555
|
+
for i in sorted(tc_index)
|
|
556
|
+
]
|
|
557
|
+
msg = Message(
|
|
558
|
+
role="assistant",
|
|
559
|
+
content=content,
|
|
560
|
+
tool_calls=tool_calls,
|
|
561
|
+
reasoning="".join(reasoning_parts) or None,
|
|
562
|
+
)
|
|
563
|
+
_log_llm_interaction(self.log_path, payload, msg, usage)
|
|
564
|
+
return msg, usage
|
|
565
|
+
|
|
566
|
+
def _sleep_backoff(
|
|
567
|
+
self,
|
|
568
|
+
attempt: int,
|
|
569
|
+
retry_after: str | None,
|
|
570
|
+
cancel_check: Callable[[], bool] | None,
|
|
571
|
+
) -> bool:
|
|
572
|
+
"""Sleep between retries; return True when aborted (cancelled).
|
|
573
|
+
|
|
574
|
+
``attempt`` is the number of the request that just failed (1 =
|
|
575
|
+
first attempt); the delay doubles per attempt, capped, with
|
|
576
|
+
jitter (``Retry-After`` wins for 429s). When ``cancel_check``
|
|
577
|
+
is given it is polled in small increments so a Ctrl-C lands
|
|
578
|
+
promptly instead of after the full backoff wait.
|
|
579
|
+
"""
|
|
580
|
+
deadline = time.monotonic() + _retry_delay(
|
|
581
|
+
attempt, retry_after, self.retry_base_delay, self.retry_max_delay
|
|
582
|
+
)
|
|
583
|
+
while True:
|
|
584
|
+
remaining = deadline - time.monotonic()
|
|
585
|
+
if remaining <= 0:
|
|
586
|
+
return False
|
|
587
|
+
if cancel_check is not None and cancel_check():
|
|
588
|
+
return True
|
|
589
|
+
time.sleep(min(0.25, remaining))
|
|
590
|
+
|
|
591
|
+
def _stream_response(
|
|
592
|
+
self,
|
|
593
|
+
payload: dict[str, Any],
|
|
594
|
+
on_delta: Callable[[str], None] | None,
|
|
595
|
+
on_tool_call: Callable[[str, str, str], None] | None,
|
|
596
|
+
usage: Usage,
|
|
597
|
+
) -> tuple[list[str], list[str], dict[int, dict[str, Any]]]:
|
|
598
|
+
"""POST a streaming request and accumulate SSE deltas.
|
|
599
|
+
|
|
600
|
+
Returns (content parts, reasoning parts, tool-call index) with
|
|
601
|
+
the same shape as `_sync_response`, so `chat()` assembles the
|
|
602
|
+
final message identically for both modes.
|
|
603
|
+
"""
|
|
604
|
+
content_parts: list[str] = []
|
|
605
|
+
reasoning_parts: list[str] = []
|
|
606
|
+
tc_index: dict[int, dict[str, Any]] = {}
|
|
607
|
+
|
|
608
|
+
with self._http.stream(
|
|
609
|
+
"POST", self._url(), headers=self._headers(stream=True), json=payload
|
|
610
|
+
) as resp:
|
|
611
|
+
if resp.status_code >= 400:
|
|
612
|
+
body = resp.read().decode("utf-8", "replace")
|
|
613
|
+
message = f"API error {resp.status_code}: {body[:500]}"
|
|
614
|
+
if resp.status_code == 401:
|
|
615
|
+
raise AuthExpiredError(message)
|
|
616
|
+
if _retryable_status(resp.status_code):
|
|
617
|
+
raise RetryableApiError(message, resp.headers.get("Retry-After"))
|
|
618
|
+
raise ApiError(message)
|
|
619
|
+
for chunk in _iter_sse(resp.iter_lines()):
|
|
620
|
+
if not chunk:
|
|
621
|
+
continue
|
|
622
|
+
if chunk == "[DONE]":
|
|
623
|
+
break
|
|
624
|
+
try:
|
|
625
|
+
data = json.loads(chunk)
|
|
626
|
+
except json.JSONDecodeError:
|
|
627
|
+
continue
|
|
628
|
+
# a 200 stream can still end in an error object
|
|
629
|
+
# (no choices); surface it instead of silently
|
|
630
|
+
# returning an empty message
|
|
631
|
+
err = _error_text(data)
|
|
632
|
+
if err:
|
|
633
|
+
raise ApiError(f"API error: {err}")
|
|
634
|
+
if data.get("usage"):
|
|
635
|
+
u = data["usage"]
|
|
636
|
+
usage.input_tokens = int(u.get("prompt_tokens") or u.get("input_tokens") or 0)
|
|
637
|
+
usage.output_tokens = int(
|
|
638
|
+
u.get("completion_tokens") or u.get("output_tokens") or 0
|
|
639
|
+
)
|
|
640
|
+
choices = data.get("choices") or []
|
|
641
|
+
if not choices:
|
|
642
|
+
continue
|
|
643
|
+
delta = choices[0].get("delta") or {}
|
|
644
|
+
if isinstance(delta.get("content"), str) and delta["content"]:
|
|
645
|
+
content_parts.append(delta["content"])
|
|
646
|
+
if on_delta:
|
|
647
|
+
on_delta(delta["content"])
|
|
648
|
+
if delta.get("reasoning_content"):
|
|
649
|
+
content_parts.append(delta["reasoning_content"])
|
|
650
|
+
reasoning_parts.append(delta["reasoning_content"])
|
|
651
|
+
if on_delta:
|
|
652
|
+
on_delta(delta["reasoning_content"])
|
|
653
|
+
for tc in delta.get("tool_calls") or []:
|
|
654
|
+
self._absorb_tool_call(
|
|
655
|
+
tc_index, self._slot_for(tc_index, tc, 0), tc, on_tool_call
|
|
656
|
+
)
|
|
657
|
+
return content_parts, reasoning_parts, tc_index
|
|
658
|
+
|
|
659
|
+
def _sync_response(
|
|
660
|
+
self,
|
|
661
|
+
payload: dict[str, Any],
|
|
662
|
+
on_delta: Callable[[str], None] | None,
|
|
663
|
+
on_tool_call: Callable[[str, str, str], None] | None,
|
|
664
|
+
usage: Usage,
|
|
665
|
+
) -> tuple[list[str], list[str], dict[int, dict[str, Any]]]:
|
|
666
|
+
"""POST a non-streaming request and parse the single response.
|
|
667
|
+
|
|
668
|
+
Returns the same shape as `_stream_response`; text deltas are
|
|
669
|
+
fired once with the complete values (reasoning first, mirroring
|
|
670
|
+
the streaming order), and tool-call arguments arrive as one
|
|
671
|
+
complete JSON string instead of fragments.
|
|
672
|
+
"""
|
|
673
|
+
resp = self._http.post(self._url(), headers=self._headers(stream=False), json=payload)
|
|
674
|
+
if resp.status_code >= 400:
|
|
675
|
+
message = f"API error {resp.status_code}: {resp.text[:500]}"
|
|
676
|
+
if resp.status_code == 401:
|
|
677
|
+
raise AuthExpiredError(message)
|
|
678
|
+
if _retryable_status(resp.status_code):
|
|
679
|
+
raise RetryableApiError(message, resp.headers.get("Retry-After"))
|
|
680
|
+
raise ApiError(message)
|
|
681
|
+
data = resp.json()
|
|
682
|
+
err = _error_text(data)
|
|
683
|
+
if err:
|
|
684
|
+
raise ApiError(f"API error: {err}")
|
|
685
|
+
u = data.get("usage")
|
|
686
|
+
if u:
|
|
687
|
+
usage.input_tokens = int(u.get("prompt_tokens") or u.get("input_tokens") or 0)
|
|
688
|
+
usage.output_tokens = int(u.get("completion_tokens") or u.get("output_tokens") or 0)
|
|
689
|
+
content_parts: list[str] = []
|
|
690
|
+
reasoning_parts: list[str] = []
|
|
691
|
+
tc_index: dict[int, dict[str, Any]] = {}
|
|
692
|
+
choice = (data.get("choices") or [{}])[0]
|
|
693
|
+
msg = choice.get("message") or {}
|
|
694
|
+
reasoning = msg.get("reasoning_content") or ""
|
|
695
|
+
content = msg.get("content") or ""
|
|
696
|
+
# some backends return content as a list of parts (multimodal);
|
|
697
|
+
# normalize to plain text like Message.text() does, so the
|
|
698
|
+
# assembled parts stay strings
|
|
699
|
+
if isinstance(content, list):
|
|
700
|
+
content = "".join(
|
|
701
|
+
p.get("text", "") if isinstance(p, dict) else (p if isinstance(p, str) else "")
|
|
702
|
+
for p in content
|
|
703
|
+
)
|
|
704
|
+
if not isinstance(reasoning, str):
|
|
705
|
+
reasoning = ""
|
|
706
|
+
# mirror the streaming order: reasoning first, then the answer
|
|
707
|
+
if reasoning:
|
|
708
|
+
reasoning_parts.append(reasoning)
|
|
709
|
+
content_parts.append(reasoning)
|
|
710
|
+
if on_delta:
|
|
711
|
+
on_delta(reasoning)
|
|
712
|
+
if content:
|
|
713
|
+
content_parts.append(content)
|
|
714
|
+
if on_delta:
|
|
715
|
+
on_delta(content)
|
|
716
|
+
for i, tc in enumerate(msg.get("tool_calls") or []):
|
|
717
|
+
# honor an explicit index when present (some backends mirror
|
|
718
|
+
# the streaming shape); position is the fallback
|
|
719
|
+
self._absorb_tool_call(tc_index, self._slot_for(tc_index, tc, i), tc, on_tool_call)
|
|
720
|
+
return content_parts, reasoning_parts, tc_index
|
|
721
|
+
|
|
722
|
+
@staticmethod
|
|
723
|
+
def _slot_for(
|
|
724
|
+
tc_index: dict[int, dict[str, Any]],
|
|
725
|
+
tc: dict[str, Any],
|
|
726
|
+
fallback: int,
|
|
727
|
+
) -> int:
|
|
728
|
+
"""The accumulator slot for one tool-call chunk.
|
|
729
|
+
|
|
730
|
+
``index`` is authoritative when it is a usable integer.
|
|
731
|
+
``tc.get("index", fallback)`` is not enough: a backend may send
|
|
732
|
+
``"index": null`` (key present, value null), and the None would
|
|
733
|
+
both land as a dict key — blowing up the final ``sorted(tc_index)``
|
|
734
|
+
with a TypeError — and merge unrelated calls into one slot.
|
|
735
|
+
|
|
736
|
+
Without a usable index the newest slot continues (a streaming
|
|
737
|
+
call's fragments arrive in order), unless the chunk carries an
|
|
738
|
+
``id`` that differs from the one already accumulated there: that
|
|
739
|
+
marks a NEW call, and merging would splice two calls together.
|
|
740
|
+
"""
|
|
741
|
+
idx = tc.get("index")
|
|
742
|
+
if isinstance(idx, int) and not isinstance(idx, bool):
|
|
743
|
+
return idx
|
|
744
|
+
if not tc_index:
|
|
745
|
+
return fallback
|
|
746
|
+
current = max(tc_index)
|
|
747
|
+
tid = tc.get("id") or ""
|
|
748
|
+
if tid and tc_index[current]["id"] and tid != tc_index[current]["id"]:
|
|
749
|
+
return current + 1
|
|
750
|
+
return current
|
|
751
|
+
|
|
752
|
+
@staticmethod
|
|
753
|
+
def _absorb_tool_call(
|
|
754
|
+
tc_index: dict[int, dict[str, Any]],
|
|
755
|
+
idx: int,
|
|
756
|
+
tc: dict[str, Any],
|
|
757
|
+
on_tool_call: Callable[[str, str, str], None] | None,
|
|
758
|
+
) -> None:
|
|
759
|
+
"""Accumulate one tool-call chunk (a streaming delta fragment or
|
|
760
|
+
a complete non-streaming call) into the index at ``idx``."""
|
|
761
|
+
entry = tc_index.setdefault(
|
|
762
|
+
idx,
|
|
763
|
+
{"id": "", "name": "", "arguments": ""},
|
|
764
|
+
)
|
|
765
|
+
fn = tc.get("function") or {}
|
|
766
|
+
entry["id"] += tc.get("id") or ""
|
|
767
|
+
entry["name"] += fn.get("name") or ""
|
|
768
|
+
frag = fn.get("arguments") or ""
|
|
769
|
+
if isinstance(frag, dict):
|
|
770
|
+
frag = json.dumps(frag, ensure_ascii=False)
|
|
771
|
+
entry["arguments"] += frag
|
|
772
|
+
if on_tool_call and frag:
|
|
773
|
+
on_tool_call(entry["name"], entry["id"], frag)
|
|
774
|
+
|
|
775
|
+
# -- non-streaming chat -------------------------------------------------
|
|
776
|
+
def chat_sync(
|
|
777
|
+
self,
|
|
778
|
+
messages: list[Message],
|
|
779
|
+
system: str | None = None,
|
|
780
|
+
temperature: float | None = None,
|
|
781
|
+
max_tokens: int | None = None,
|
|
782
|
+
reasoning_effort: str | None = None,
|
|
783
|
+
cancel_check: Callable[[], bool] | None = None,
|
|
784
|
+
) -> tuple[Message, Usage]:
|
|
785
|
+
"""Non-streaming request; used for compaction, titles, summary."""
|
|
786
|
+
return self.chat(
|
|
787
|
+
messages,
|
|
788
|
+
tools=None,
|
|
789
|
+
system=system,
|
|
790
|
+
temperature=temperature,
|
|
791
|
+
max_tokens=max_tokens,
|
|
792
|
+
reasoning_effort=reasoning_effort,
|
|
793
|
+
stream=False,
|
|
794
|
+
cancel_check=cancel_check,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _default_api_key() -> str | None:
|
|
799
|
+
return os.environ.get("OPENAI_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or None
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _abort_inflight_sockets(client: httpx.Client) -> None:
|
|
803
|
+
"""Wake any blocked stream reads by shutting down live pool sockets.
|
|
804
|
+
|
|
805
|
+
Reaches through httpx/httpcore internals (transport -> pool ->
|
|
806
|
+
connection -> network stream -> raw socket) and calls
|
|
807
|
+
``shutdown(SHUT_RDWR)`` on each live connection. On Linux this is
|
|
808
|
+
the only reliable way to interrupt a ``recv`` already blocked in the
|
|
809
|
+
kernel from another thread — ``close()`` cannot do it. Shutting
|
|
810
|
+
down an idle socket is harmless (the pool is closed right after
|
|
811
|
+
anyway); any failure is ignored (best effort).
|
|
812
|
+
"""
|
|
813
|
+
import socket as _socket
|
|
814
|
+
|
|
815
|
+
pool = getattr(getattr(client, "_transport", None), "_pool", None)
|
|
816
|
+
for conn in getattr(pool, "_connections", None) or ():
|
|
817
|
+
try:
|
|
818
|
+
stream = getattr(getattr(conn, "_connection", None), "_network_stream", None)
|
|
819
|
+
sock = stream.get_extra_info("socket") if stream is not None else None
|
|
820
|
+
except Exception: # noqa: BLE001 - best effort
|
|
821
|
+
sock = None
|
|
822
|
+
if sock is not None:
|
|
823
|
+
with contextlib.suppress(OSError):
|
|
824
|
+
sock.shutdown(_socket.SHUT_RDWR)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _iter_sse(lines: Iterator[str]) -> Iterator[str]:
|
|
828
|
+
"""Yield SSE data payloads from a line iterator."""
|
|
829
|
+
for line in lines:
|
|
830
|
+
line = line.strip()
|
|
831
|
+
if line.startswith("data:"):
|
|
832
|
+
yield line[len("data:") :].strip()
|