synapse-cli-agent 0.1.13__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 (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,370 @@
1
+ """Configurable image-to-text adaptation for non-vision chat models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import binascii
8
+ import hashlib
9
+ import logging
10
+ import os
11
+ import re
12
+ import threading
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from synapse.content.multimodal import ALLOWED_MIME
19
+
20
+ _DEFAULT_PROMPT = (
21
+ "Describe this image accurately for a text-only coding assistant. "
22
+ "Extract visible text, UI state, code, tables, errors, and spatial relationships. "
23
+ "Return concise Markdown and do not mention this instruction."
24
+ )
25
+ _DATA_URL_RE = re.compile(
26
+ r"^data:(?P<mime>image/[A-Za-z0-9.+-]+);base64,(?P<data>[A-Za-z0-9+/=]+)$",
27
+ re.IGNORECASE,
28
+ )
29
+ _ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
30
+ _logger = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class VisionModelConfig:
35
+ """Independent OpenAI-compatible vision endpoint configuration."""
36
+
37
+ model: str
38
+ base_url: str = "https://api.openai.com/v1"
39
+ api_key: str | None = None
40
+ timeout_secs: float = 45.0
41
+ max_input_bytes: int = 10 * 1024 * 1024
42
+ max_retries: int = 2
43
+ prompt: str = _DEFAULT_PROMPT
44
+ fallback_model: str | None = None
45
+ allow_remote_urls: bool = False
46
+ think: bool = False
47
+
48
+ @classmethod
49
+ def from_mapping(cls, raw: Any) -> VisionModelConfig | None:
50
+ if not isinstance(raw, dict):
51
+ return None
52
+ model = _expand(raw.get("model"))
53
+ if not model:
54
+ return None
55
+ api_key = _expand(raw.get("api_key"))
56
+ api_key_env = _expand(raw.get("api_key_env"))
57
+ if api_key_env:
58
+ api_key = os.environ.get(api_key_env) or api_key
59
+ try:
60
+ timeout = max(1.0, float(raw.get("timeout_secs", 45)))
61
+ except (TypeError, ValueError):
62
+ timeout = 45.0
63
+ try:
64
+ max_input = max(1, int(raw.get("max_input_bytes", 10 * 1024 * 1024)))
65
+ except (TypeError, ValueError):
66
+ max_input = 10 * 1024 * 1024
67
+ try:
68
+ retries = max(1, int(raw.get("max_retries", 2)))
69
+ except (TypeError, ValueError):
70
+ retries = 2
71
+ return cls(
72
+ model=model,
73
+ base_url=_expand(raw.get("base_url")) or cls.base_url,
74
+ api_key=api_key,
75
+ timeout_secs=timeout,
76
+ max_input_bytes=max_input,
77
+ max_retries=retries,
78
+ prompt=_expand(raw.get("prompt")) or _DEFAULT_PROMPT,
79
+ fallback_model=_expand(raw.get("fallback_model")) or None,
80
+ allow_remote_urls=_parse_bool(raw.get("allow_remote_urls", False)),
81
+ think=_parse_bool(raw.get("think", False)),
82
+ )
83
+
84
+ @classmethod
85
+ def from_settings(cls, settings: Any) -> VisionModelConfig | None:
86
+ return cls.from_mapping(getattr(settings, "vision_model", None))
87
+
88
+ @classmethod
89
+ def from_registry(cls, registry: Any, settings: Any) -> VisionModelConfig | None:
90
+ raw = getattr(registry, "vision_model", None)
91
+ if raw is None:
92
+ raw = getattr(settings, "vision_model", None)
93
+ return cls.from_mapping(raw)
94
+
95
+
96
+ class VisionModelError(RuntimeError):
97
+ """A safe, user-facing vision service failure."""
98
+
99
+
100
+ @dataclass
101
+ class VisionModelClient:
102
+ config: VisionModelConfig
103
+ _cache: dict[str, str] = field(default_factory=dict)
104
+
105
+ async def describe_data_url(self, data_url: str, *, extra_prompt: str | None = None) -> str:
106
+ raw = _parse_data_url(data_url, self.config.max_input_bytes)
107
+ if raw is None:
108
+ raise VisionModelError("invalid image data")
109
+ cache_key = _cache_key(data_url, extra_prompt)
110
+ cached = self._cache.get(cache_key)
111
+ if cached is not None:
112
+ return cached
113
+ description = await self._call(data_url, extra_prompt=extra_prompt)
114
+ self._cache[cache_key] = description
115
+ return description
116
+
117
+ async def describe_url(self, url: str, *, extra_prompt: str | None = None) -> str:
118
+ if not self.config.allow_remote_urls:
119
+ raise VisionModelError("remote image URLs are disabled")
120
+ if not (url.startswith("http://") or url.startswith("https://")):
121
+ raise VisionModelError("unsupported image URL")
122
+ cache_key = _cache_key(url, extra_prompt)
123
+ cached = self._cache.get(cache_key)
124
+ if cached is not None:
125
+ return cached
126
+ description = await self._call(url, extra_prompt=extra_prompt)
127
+ self._cache[cache_key] = description
128
+ return description
129
+
130
+ async def _call(self, image_url: str, *, extra_prompt: str | None) -> str:
131
+ prompt = self.config.prompt
132
+ if extra_prompt and extra_prompt.strip():
133
+ prompt = f"{prompt}\nAdditional focus: {extra_prompt.strip()}"
134
+ models = [self.config.model]
135
+ if self.config.fallback_model and self.config.fallback_model not in models:
136
+ models.append(self.config.fallback_model)
137
+ last_error: Exception | None = None
138
+ for model in models:
139
+ for attempt in range(self.config.max_retries):
140
+ try:
141
+ return await self._request(model, image_url, prompt)
142
+ except Exception as exc: # noqa: BLE001
143
+ last_error = exc
144
+ if not _retryable(exc) or attempt + 1 >= self.config.max_retries:
145
+ break
146
+ raise VisionModelError("vision service request failed") from last_error
147
+
148
+ async def _request(self, model: str, image_url: str, prompt: str) -> str:
149
+ if not self.config.api_key:
150
+ _logger.warning(
151
+ "Vision image description has no API key: model=%s base_url=%s",
152
+ model,
153
+ self.config.base_url,
154
+ )
155
+ headers = {"Authorization": f"Bearer {self.config.api_key}"} if self.config.api_key else {}
156
+ body = {
157
+ "model": model,
158
+ "messages": [
159
+ {
160
+ "role": "user",
161
+ "content": [
162
+ {"type": "image_url", "image_url": {"url": image_url}},
163
+ {"type": "text", "text": prompt},
164
+ ],
165
+ }
166
+ ],
167
+ }
168
+ if self.config.think:
169
+ body["thinking"] = {"type": "enabled"}
170
+ url = f"{self.config.base_url.rstrip('/')}/chat/completions"
171
+ async with httpx.AsyncClient(timeout=self.config.timeout_secs) as client:
172
+ response = await client.post(url, headers=headers, json=body)
173
+ if response.status_code >= 400:
174
+ _logger.warning(
175
+ "Vision image description HTTP failure: model=%s base_url=%s status=%s",
176
+ model,
177
+ self.config.base_url,
178
+ response.status_code,
179
+ )
180
+ raise VisionModelError(f"HTTP {response.status_code}")
181
+ try:
182
+ payload = response.json()
183
+ content = payload["choices"][0]["message"]["content"]
184
+ except (KeyError, IndexError, TypeError, ValueError) as exc:
185
+ _logger.warning(
186
+ "Vision image description returned an unexpected response: model=%s base_url=%s",
187
+ model,
188
+ self.config.base_url,
189
+ )
190
+ raise VisionModelError("empty vision response") from exc
191
+ text = _content_text(content)
192
+ if not text:
193
+ raise VisionModelError("empty vision response")
194
+ return text
195
+
196
+
197
+ def rewrite_messages_sync(messages: list[Any], client: VisionModelClient | None) -> list[Any]:
198
+ """Synchronous fallback used by ``invoke``/``stream`` paths."""
199
+ if client is None:
200
+ return _rewrite_without_vision(messages)
201
+ return _run_coroutine_sync(rewrite_messages(messages, client))
202
+
203
+
204
+ def _run_coroutine_sync(coro: Any) -> Any:
205
+ """Run a coroutine without nesting ``asyncio.run`` in an active event loop."""
206
+ try:
207
+ asyncio.get_running_loop()
208
+ except RuntimeError:
209
+ return asyncio.run(coro)
210
+
211
+ result: list[Any] = []
212
+ error: list[BaseException] = []
213
+
214
+ def runner() -> None:
215
+ try:
216
+ result.append(asyncio.run(coro))
217
+ except BaseException as exc: # noqa: BLE001
218
+ error.append(exc)
219
+
220
+ thread = threading.Thread(target=runner, name="synapse-vision-sync", daemon=True)
221
+ thread.start()
222
+ thread.join()
223
+ if error:
224
+ raise error[0]
225
+ return result[0] if result else None
226
+
227
+
228
+ async def rewrite_messages(messages: list[Any], client: VisionModelClient | None) -> list[Any]:
229
+ """Replace image blocks with text while keeping message types intact."""
230
+ rewritten: list[Any] = []
231
+ for message in messages:
232
+ content = getattr(message, "content", None)
233
+ if not isinstance(content, list):
234
+ rewritten.append(message)
235
+ continue
236
+ new_content: list[Any] = []
237
+ changed = False
238
+ for block in content:
239
+ replacement = await _describe_block(block, client)
240
+ if replacement is None:
241
+ new_content.append(block)
242
+ else:
243
+ new_content.append({"type": "text", "text": replacement})
244
+ changed = True
245
+ if changed:
246
+ try:
247
+ message = message.model_copy(update={"content": new_content})
248
+ except AttributeError:
249
+ message = message.copy(update={"content": new_content})
250
+ rewritten.append(message)
251
+ return rewritten
252
+
253
+
254
+ async def _describe_block(block: Any, client: VisionModelClient | None) -> str | None:
255
+ if not isinstance(block, dict):
256
+ return None
257
+ block_type = str(block.get("type") or "").casefold()
258
+ data_url: str | None = None
259
+ if block_type == "image_url":
260
+ image_url = block.get("image_url")
261
+ url = image_url.get("url") if isinstance(image_url, dict) else image_url
262
+ if isinstance(url, str):
263
+ if url.startswith("data:"):
264
+ data_url = url
265
+ elif url.startswith(("http://", "https://")):
266
+ if client is None:
267
+ return _unavailable()
268
+ try:
269
+ return _render_description(await client.describe_url(url))
270
+ except VisionModelError:
271
+ return _unavailable()
272
+ elif block_type in {"image", "input_image"}:
273
+ source = block.get("source")
274
+ if isinstance(source, dict) and source.get("type") == "base64":
275
+ mime = str(source.get("media_type") or source.get("mime_type") or "image/png")
276
+ data = str(source.get("data") or "")
277
+ data_url = f"data:{mime};base64,{data}"
278
+ elif block.get("base64"):
279
+ mime = str(block.get("mime_type") or block.get("media_type") or "image/png")
280
+ data_url = f"data:{mime};base64,{block.get('base64')}"
281
+ if data_url is None:
282
+ return None
283
+ if client is None:
284
+ return _unavailable()
285
+ try:
286
+ return _render_description(await client.describe_data_url(data_url))
287
+ except VisionModelError as exc:
288
+ _logger.warning("Vision image description failed: %s", exc)
289
+ return _unavailable()
290
+
291
+
292
+ def _rewrite_without_vision(messages: list[Any]) -> list[Any]:
293
+ """Remove raw image blocks when no vision service is configured."""
294
+ return _run_coroutine_sync(rewrite_messages(messages, None))
295
+
296
+
297
+ def _render_description(description: str) -> str:
298
+ return f"[image]\n{description.strip()}\n[/image]"
299
+
300
+
301
+ def _unavailable() -> str:
302
+ return "[image unavailable: automatic description failed]"
303
+
304
+
305
+ def _parse_data_url(value: str, limit: int) -> tuple[bytes, str] | None:
306
+ match = _DATA_URL_RE.match(value.strip())
307
+ if not match:
308
+ return None
309
+ mime = match.group("mime").lower()
310
+ if mime == "image/jpg":
311
+ mime = "image/jpeg"
312
+ if mime not in ALLOWED_MIME:
313
+ return None
314
+ try:
315
+ data = base64.b64decode(match.group("data"), validate=True)
316
+ except (ValueError, binascii.Error):
317
+ return None
318
+ if not data or len(data) > limit:
319
+ return None
320
+ return data, mime
321
+
322
+
323
+ def _content_text(content: Any) -> str:
324
+ if isinstance(content, str):
325
+ return content.strip()
326
+ if isinstance(content, list):
327
+ parts = []
328
+ for item in content:
329
+ if isinstance(item, str):
330
+ parts.append(item)
331
+ elif isinstance(item, dict) and isinstance(item.get("text"), str):
332
+ parts.append(item["text"])
333
+ return "\n".join(parts).strip()
334
+ return ""
335
+
336
+
337
+ def _cache_key(source: str, extra_prompt: str | None) -> str:
338
+ return hashlib.sha256(f"{source}\n{extra_prompt or ''}".encode()).hexdigest()
339
+
340
+
341
+ def _retryable(exc: Exception) -> bool:
342
+ text = str(exc).lower()
343
+ return (
344
+ "http 429" in text
345
+ or "http 5" in text
346
+ or isinstance(exc, (httpx.HTTPError, TimeoutError))
347
+ )
348
+
349
+
350
+ def _parse_bool(value: Any) -> bool:
351
+ if isinstance(value, bool):
352
+ return value
353
+ if isinstance(value, str):
354
+ normalized = value.strip().casefold()
355
+ if normalized in {"true", "1", "yes", "on", "enabled"}:
356
+ return True
357
+ if normalized in {"false", "0", "no", "off", "disabled"}:
358
+ return False
359
+ return bool(value)
360
+
361
+
362
+ def _expand(value: Any) -> str:
363
+ if value is None:
364
+ return ""
365
+ text = str(value).strip()
366
+
367
+ def replace(match: re.Match[str]) -> str:
368
+ return os.environ.get(match.group(1) or match.group(2), "")
369
+
370
+ return _ENV_RE.sub(replace, text)
@@ -0,0 +1,199 @@
1
+ """Provider-specific HTTP setup for LLM SDK clients.
2
+
3
+ OpenAI-compatible models use one dedicated ``httpx.AsyncClient`` per cached
4
+ model. Clients share one process SSLContext, but never share a connection pool
5
+ across models or event loops. Anthropic keeps its own lazy async client path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # 5 minutes — idle pooled connections only.
18
+ HTTP_KEEPALIVE_EXPIRY_SECONDS = 300.0
19
+
20
+ _OPENAI_PATCHED = False
21
+ _ANTHROPIC_PATCHED = False
22
+ # Backward-compatible reset flag for callers/tests of the combined helper.
23
+ _PATCHED = False
24
+
25
+ _LONG_KEEPALIVE_LIMITS = httpx.Limits(
26
+ max_connections=1000,
27
+ max_keepalive_connections=100,
28
+ keepalive_expiry=HTTP_KEEPALIVE_EXPIRY_SECONDS,
29
+ )
30
+
31
+
32
+ def long_keepalive_limits() -> httpx.Limits:
33
+ return _LONG_KEEPALIVE_LIMITS
34
+
35
+
36
+ def enable_openai_long_keepalive_defaults() -> None:
37
+ """Patch only OpenAI defaults; never import Anthropic on this path."""
38
+ global _OPENAI_PATCHED
39
+ if _OPENAI_PATCHED:
40
+ return
41
+ try:
42
+ import openai._base_client as openai_base
43
+ import openai._constants as openai_constants
44
+
45
+ openai_constants.DEFAULT_CONNECTION_LIMITS = _LONG_KEEPALIVE_LIMITS
46
+ openai_base.DEFAULT_CONNECTION_LIMITS = _LONG_KEEPALIVE_LIMITS
47
+ except Exception as exc: # noqa: BLE001
48
+ logger.debug("openai keep-alive patch skipped: %s", exc)
49
+
50
+ try:
51
+ from langchain_openai.chat_models import base as openai_chat_base
52
+
53
+ for name in (
54
+ "_cached_sync_httpx_client",
55
+ "_cached_async_httpx_client",
56
+ "_get_default_httpx_client",
57
+ "_get_default_async_httpx_client",
58
+ ):
59
+ fn = getattr(openai_chat_base, name, None)
60
+ if fn is not None and hasattr(fn, "cache_clear"):
61
+ fn.cache_clear()
62
+ except Exception as exc: # noqa: BLE001
63
+ logger.debug("langchain_openai cache clear skipped: %s", exc)
64
+ _OPENAI_PATCHED = True
65
+
66
+
67
+ def enable_anthropic_long_keepalive_defaults() -> None:
68
+ """Patch only Anthropic defaults; its async client remains lazily created."""
69
+ global _ANTHROPIC_PATCHED
70
+ if _ANTHROPIC_PATCHED:
71
+ return
72
+ try:
73
+ import anthropic._base_client as anthropic_base
74
+ import anthropic._constants as anthropic_constants
75
+
76
+ anthropic_constants.DEFAULT_CONNECTION_LIMITS = _LONG_KEEPALIVE_LIMITS
77
+ anthropic_base.DEFAULT_CONNECTION_LIMITS = _LONG_KEEPALIVE_LIMITS
78
+ except Exception as exc: # noqa: BLE001
79
+ logger.debug("anthropic keep-alive patch skipped: %s", exc)
80
+
81
+ try:
82
+ from langchain_anthropic.chat_models import (
83
+ _get_default_async_httpx_client,
84
+ _get_default_httpx_client,
85
+ )
86
+
87
+ if hasattr(_get_default_httpx_client, "cache_clear"):
88
+ _get_default_httpx_client.cache_clear()
89
+ if hasattr(_get_default_async_httpx_client, "cache_clear"):
90
+ _get_default_async_httpx_client.cache_clear()
91
+ except Exception as exc: # noqa: BLE001
92
+ logger.debug("langchain_anthropic cache clear skipped: %s", exc)
93
+ _ANTHROPIC_PATCHED = True
94
+
95
+
96
+ def enable_long_keepalive_http_defaults() -> None:
97
+ """Backward-compatible combined helper for callers that need both SDKs."""
98
+ global _PATCHED
99
+ if _PATCHED:
100
+ return
101
+ enable_openai_long_keepalive_defaults()
102
+ enable_anthropic_long_keepalive_defaults()
103
+ _PATCHED = True
104
+
105
+
106
+ def shared_openai_ssl_context():
107
+ """Return LangChain OpenAI's process singleton SSLContext."""
108
+ from langchain_openai.chat_models.base import global_ssl_context
109
+
110
+ return global_ssl_context
111
+
112
+
113
+ def build_openai_async_http_client(
114
+ *,
115
+ timeout: Any = None,
116
+ proxy: str | None = None,
117
+ ) -> httpx.AsyncClient:
118
+ """Create one model-local AsyncClient on the process async runtime."""
119
+ enable_openai_long_keepalive_defaults()
120
+ from synapse.runtime.async_runtime import get_async_runtime
121
+
122
+ runtime = get_async_runtime()
123
+
124
+ async def _build() -> httpx.AsyncClient:
125
+ from openai import DEFAULT_TIMEOUT
126
+
127
+ kwargs: dict[str, Any] = {
128
+ "verify": shared_openai_ssl_context(),
129
+ "limits": _LONG_KEEPALIVE_LIMITS,
130
+ "timeout": DEFAULT_TIMEOUT if timeout is None else timeout,
131
+ }
132
+ if proxy:
133
+ kwargs["proxy"] = proxy
134
+ return httpx.AsyncClient(**kwargs)
135
+
136
+ client = runtime.run(_build())
137
+ runtime.track_connection(client)
138
+ return client
139
+
140
+
141
+ def close_model_async_http_client(model: Any) -> None:
142
+ if bool(getattr(model, "_coding_websocket", False)):
143
+ try:
144
+ from synapse.runtime.async_runtime import get_async_runtime
145
+
146
+ get_async_runtime().close_connection(model)
147
+ except Exception: # noqa: BLE001
148
+ pass
149
+ client = getattr(model, "_coding_http_async_client", None)
150
+ if client is None:
151
+ return
152
+ try:
153
+ from synapse.runtime.async_runtime import get_async_runtime
154
+
155
+ get_async_runtime().close_connection(client)
156
+ except Exception: # noqa: BLE001
157
+ pass
158
+ try:
159
+ model._coding_http_async_client = None
160
+ except Exception: # noqa: BLE001
161
+ pass
162
+
163
+
164
+ # --- deprecated API (no-op / thin wrappers) so old imports do not crash ---
165
+
166
+
167
+ def get_shared_http_clients() -> tuple[Any, Any]:
168
+ """Deprecated. Process-global shared pools remain disabled."""
169
+ enable_long_keepalive_http_defaults()
170
+ raise RuntimeError("shared HTTP clients are disabled; use model-local AsyncClient")
171
+
172
+
173
+ get_shared_openai_http_clients = get_shared_http_clients
174
+
175
+
176
+ def inject_openai_http_clients(kwargs: dict[str, Any]) -> dict[str, Any]:
177
+ """Compatibility helper: apply OpenAI defaults without injecting a sync client."""
178
+ enable_openai_long_keepalive_defaults()
179
+ return dict(kwargs)
180
+
181
+
182
+ def apply_keepalive_http_clients_to_model(model: Any) -> Any:
183
+ """Compatibility helper for Anthropic's lazy client path."""
184
+ enable_anthropic_long_keepalive_defaults()
185
+ return model
186
+
187
+
188
+ def close_shared_http_clients() -> None:
189
+ """Deprecated no-op (no process-global clients)."""
190
+ return
191
+
192
+
193
+ def client_keepalive_expiry(client: httpx.Client | httpx.AsyncClient) -> float | None:
194
+ transport = getattr(client, "_transport", None)
195
+ pool = getattr(transport, "_pool", None) if transport is not None else None
196
+ expiry = getattr(pool, "_keepalive_expiry", None) if pool is not None else None
197
+ if expiry is None:
198
+ return None
199
+ return float(expiry)
@@ -0,0 +1,90 @@
1
+ """OpenAI-compatible provider patches for non-standard fields.
2
+
3
+ LangChain's ``ChatOpenAI`` intentionally drops third-party fields such as
4
+ DeepSeek's ``reasoning_content`` (see langchain_openai docs). Our coding agent
5
+ targets OpenAI-compatible gateways (DeepSeek V4 etc.), so we restore:
6
+
7
+ 1. inbound stream deltas: ``delta.reasoning_content`` ->
8
+ ``AIMessageChunk.additional_kwargs['reasoning_content']``
9
+ 2. inbound complete messages: same field on ``AIMessage``
10
+ 3. outbound request messages: send ``reasoning_content`` back when present
11
+ (required by DeepSeek when an assistant turn includes tool calls)
12
+
13
+ This module is safe to import multiple times (idempotent patch).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ _PATCHED = False
21
+
22
+
23
+ def enable_openai_compat_reasoning_patch() -> None:
24
+ """Idempotently patch langchain_openai message converters."""
25
+ global _PATCHED
26
+ if _PATCHED:
27
+ return
28
+
29
+ from langchain_openai.chat_models import base as oai_base
30
+
31
+ orig_delta = oai_base._convert_delta_to_message_chunk
32
+ orig_dict = oai_base._convert_dict_to_message
33
+ orig_to_dict = oai_base._convert_message_to_dict
34
+
35
+ def _convert_delta_to_message_chunk(_dict, default_class): # type: ignore[no-untyped-def]
36
+ chunk = orig_delta(_dict, default_class)
37
+ # Preserve provider reasoning deltas (DeepSeek / some gateways).
38
+ reasoning = _dict.get("reasoning_content")
39
+ if reasoning:
40
+ ak = dict(getattr(chunk, "additional_kwargs", None) or {})
41
+ # Stream chunks are incremental; keep delta text as-is (UI concatenates).
42
+ ak["reasoning_content"] = reasoning
43
+ try:
44
+ chunk.additional_kwargs = ak
45
+ except Exception: # noqa: BLE001
46
+ object.__setattr__(chunk, "additional_kwargs", ak)
47
+ return chunk
48
+
49
+ def _convert_dict_to_message(_dict): # type: ignore[no-untyped-def]
50
+ msg = orig_dict(_dict)
51
+ reasoning = _dict.get("reasoning_content")
52
+ if reasoning and hasattr(msg, "additional_kwargs"):
53
+ ak = dict(msg.additional_kwargs or {})
54
+ ak["reasoning_content"] = reasoning
55
+ try:
56
+ msg.additional_kwargs = ak
57
+ except Exception: # noqa: BLE001
58
+ object.__setattr__(msg, "additional_kwargs", ak)
59
+ return msg
60
+
61
+ def _convert_message_to_dict(message, api="chat/completions"): # type: ignore[no-untyped-def]
62
+ message_dict = orig_to_dict(message, api=api)
63
+ # DeepSeek tool multi-turn requires returning prior reasoning_content.
64
+ ak = getattr(message, "additional_kwargs", None) or {}
65
+ if isinstance(ak, dict):
66
+ reasoning = ak.get("reasoning_content")
67
+ if reasoning:
68
+ message_dict["reasoning_content"] = reasoning
69
+ return message_dict
70
+
71
+ oai_base._convert_delta_to_message_chunk = _convert_delta_to_message_chunk
72
+ oai_base._convert_dict_to_message = _convert_dict_to_message
73
+ oai_base._convert_message_to_dict = _convert_message_to_dict
74
+ _PATCHED = True
75
+
76
+
77
+ def deepseek_thinking_kwargs(
78
+ *,
79
+ enabled: bool = True,
80
+ reasoning_effort: str = "high",
81
+ ) -> dict[str, Any]:
82
+ """Request kwargs that enable DeepSeek V4 thinking mode."""
83
+ if not enabled:
84
+ return {
85
+ "extra_body": {"thinking": {"type": "disabled"}},
86
+ }
87
+ return {
88
+ "reasoning_effort": reasoning_effort,
89
+ "extra_body": {"thinking": {"type": "enabled"}},
90
+ }