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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- 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
|
+
}
|