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.
Files changed (61) hide show
  1. python_agent_harness/__init__.py +20 -0
  2. python_agent_harness/__main__.py +5 -0
  3. python_agent_harness/agent.py +703 -0
  4. python_agent_harness/cli.py +273 -0
  5. python_agent_harness/client.py +832 -0
  6. python_agent_harness/commands.py +181 -0
  7. python_agent_harness/config.py +464 -0
  8. python_agent_harness/context_manager.py +100 -0
  9. python_agent_harness/diffrender.py +84 -0
  10. python_agent_harness/mcp/__init__.py +21 -0
  11. python_agent_harness/mcp/client.py +161 -0
  12. python_agent_harness/mcp/config.py +130 -0
  13. python_agent_harness/mcp/manager.py +290 -0
  14. python_agent_harness/models.py +149 -0
  15. python_agent_harness/persistence.py +297 -0
  16. python_agent_harness/planmode.py +112 -0
  17. python_agent_harness/prompts/agent.md +362 -0
  18. python_agent_harness/prompts/build-switch.md +5 -0
  19. python_agent_harness/prompts/commands/explain.md +13 -0
  20. python_agent_harness/prompts/compact.md +33 -0
  21. python_agent_harness/prompts/initialize.md +66 -0
  22. python_agent_harness/prompts/plan-mode.md +70 -0
  23. python_agent_harness/prompts/plan.md +26 -0
  24. python_agent_harness/prompts/review.md +100 -0
  25. python_agent_harness/prompts/subagent.md +208 -0
  26. python_agent_harness/prompts/summary.md +11 -0
  27. python_agent_harness/prompts/task-completion-rules.md +50 -0
  28. python_agent_harness/prompts/title.md +44 -0
  29. python_agent_harness/prompts.py +498 -0
  30. python_agent_harness/session.py +781 -0
  31. python_agent_harness/subagent.py +61 -0
  32. python_agent_harness/token_estimator.py +125 -0
  33. python_agent_harness/tool_runner.py +247 -0
  34. python_agent_harness/tools/__init__.py +56 -0
  35. python_agent_harness/tools/agent_tool.py +75 -0
  36. python_agent_harness/tools/base.py +147 -0
  37. python_agent_harness/tools/bash.py +298 -0
  38. python_agent_harness/tools/edit.py +272 -0
  39. python_agent_harness/tools/filesystem.py +180 -0
  40. python_agent_harness/tools/glob.py +161 -0
  41. python_agent_harness/tools/grep.py +149 -0
  42. python_agent_harness/tools/insert.py +61 -0
  43. python_agent_harness/tools/mcp.py +203 -0
  44. python_agent_harness/tools/mkdir.py +30 -0
  45. python_agent_harness/tools/planexit.py +45 -0
  46. python_agent_harness/tools/question.py +70 -0
  47. python_agent_harness/tools/read.py +104 -0
  48. python_agent_harness/tools/skill.py +32 -0
  49. python_agent_harness/tools/todo.py +60 -0
  50. python_agent_harness/tools/write.py +56 -0
  51. python_agent_harness/tui/__init__.py +68 -0
  52. python_agent_harness/tui/commands.py +652 -0
  53. python_agent_harness/tui/core.py +385 -0
  54. python_agent_harness/tui/input.py +412 -0
  55. python_agent_harness/tui/render.py +535 -0
  56. python_agent_harness-1.5.0.dist-info/METADATA +251 -0
  57. python_agent_harness-1.5.0.dist-info/RECORD +61 -0
  58. python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
  59. python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
  60. python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
  61. 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()