devcopilot 0.2.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 (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
@@ -0,0 +1,278 @@
1
+ """Outbound HTTP for web_search / web_fetch (client, body caps, logging)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import socket
7
+ from collections.abc import AsyncIterator
8
+ from urllib.parse import urljoin, urlparse
9
+
10
+ import aiohttp
11
+ import httpx
12
+ from aiohttp import ClientSession, ClientTimeout, TCPConnector
13
+ from aiohttp.abc import AbstractResolver, ResolveResult
14
+ from loguru import logger
15
+
16
+ from . import constants
17
+ from .constants import (
18
+ _MAX_FETCH_CHARS,
19
+ _MAX_SEARCH_RESULTS,
20
+ _REDIRECT_RESPONSE_BODY_CAP_BYTES,
21
+ _REQUEST_TIMEOUT_S,
22
+ _WEB_FETCH_REDIRECT_STATUSES,
23
+ _WEB_TOOL_HTTP_HEADERS,
24
+ )
25
+ from .egress import (
26
+ WebFetchEgressPolicy,
27
+ WebFetchEgressViolation,
28
+ get_validated_stream_addrinfos_for_egress,
29
+ )
30
+ from .parsers import HTMLTextParser, SearchResultParser
31
+
32
+
33
+ def _safe_public_host_for_logs(url: str) -> str:
34
+ host = urlparse(url).hostname or ""
35
+ return host[:253]
36
+
37
+
38
+ def _log_web_tool_failure(
39
+ tool_name: str,
40
+ error: BaseException,
41
+ *,
42
+ fetch_url: str | None = None,
43
+ ) -> None:
44
+ exc_type = type(error).__name__
45
+ if isinstance(error, WebFetchEgressViolation):
46
+ host = _safe_public_host_for_logs(fetch_url) if fetch_url else ""
47
+ logger.warning(
48
+ "web_tool_egress_rejected tool={} exc_type={} host={!r}",
49
+ tool_name,
50
+ exc_type,
51
+ host,
52
+ )
53
+ return
54
+ if tool_name == "web_fetch" and fetch_url:
55
+ logger.warning(
56
+ "web_tool_failure tool={} exc_type={} host={!r}",
57
+ tool_name,
58
+ exc_type,
59
+ _safe_public_host_for_logs(fetch_url),
60
+ )
61
+ else:
62
+ logger.warning("web_tool_failure tool={} exc_type={}", tool_name, exc_type)
63
+
64
+
65
+ def _web_tool_client_error_summary(
66
+ tool_name: str,
67
+ error: BaseException,
68
+ *,
69
+ verbose: bool,
70
+ ) -> str:
71
+ if verbose:
72
+ return f"{tool_name} failed: {type(error).__name__}"
73
+ return "Web tool request failed."
74
+
75
+
76
+ async def _iter_response_body_under_cap(
77
+ response: httpx.Response, max_bytes: int
78
+ ) -> AsyncIterator[bytes]:
79
+ if max_bytes <= 0:
80
+ return
81
+ received = 0
82
+ async for chunk in response.aiter_bytes(chunk_size=65_536):
83
+ if received >= max_bytes:
84
+ break
85
+ remaining = max_bytes - received
86
+ if len(chunk) <= remaining:
87
+ received += len(chunk)
88
+ yield chunk
89
+ if received >= max_bytes:
90
+ break
91
+ else:
92
+ yield chunk[:remaining]
93
+ break
94
+
95
+
96
+ async def _drain_response_body_capped(response: httpx.Response, max_bytes: int) -> None:
97
+ async for _ in _iter_response_body_under_cap(response, max_bytes):
98
+ pass
99
+
100
+
101
+ async def _read_response_body_capped(response: httpx.Response, max_bytes: int) -> bytes:
102
+ return b"".join(
103
+ [piece async for piece in _iter_response_body_under_cap(response, max_bytes)]
104
+ )
105
+
106
+
107
+ _NUMERIC_RESOLVE_FLAGS = socket.AI_NUMERICHOST | socket.AI_NUMERICSERV
108
+ _NAME_RESOLVE_FLAGS = socket.NI_NUMERICHOST | socket.NI_NUMERICSERV
109
+
110
+
111
+ def getaddrinfo_rows_to_resolve_results(
112
+ host: str, addrinfos: list[tuple]
113
+ ) -> list[ResolveResult]:
114
+ """Map :func:`socket.getaddrinfo` rows to aiohttp :class:`ResolveResult` (ThreadedResolver logic)."""
115
+ out: list[ResolveResult] = []
116
+ for family, _type, proto, _canon, sockaddr in addrinfos:
117
+ if family == socket.AF_INET6:
118
+ if len(sockaddr) < 3:
119
+ continue
120
+ if sockaddr[3]:
121
+ resolved_host, port = socket.getnameinfo(sockaddr, _NAME_RESOLVE_FLAGS)
122
+ else:
123
+ resolved_host, port = sockaddr[:2]
124
+ else:
125
+ assert family == socket.AF_INET, family
126
+ resolved_host, port = sockaddr[0], sockaddr[1]
127
+ resolved_host = str(resolved_host)
128
+ port = int(port)
129
+ out.append(
130
+ ResolveResult(
131
+ hostname=host,
132
+ host=resolved_host,
133
+ port=int(port),
134
+ family=family,
135
+ proto=proto,
136
+ flags=_NUMERIC_RESOLVE_FLAGS,
137
+ )
138
+ )
139
+ return out
140
+
141
+
142
+ class _PinnedEgressStaticResolver(AbstractResolver):
143
+ """Return only pre-validated :class:`ResolveResult` for the outbound request."""
144
+
145
+ def __init__(self, results: list[ResolveResult]) -> None:
146
+ self._results = results
147
+
148
+ async def resolve(
149
+ self, host: str, port: int = 0, family: int = socket.AF_INET
150
+ ) -> list[ResolveResult]:
151
+ return self._results
152
+
153
+ async def close(self) -> None: # pragma: no cover - aiohttp contract
154
+ return
155
+
156
+
157
+ async def _read_aiohttp_body_capped(
158
+ response: aiohttp.ClientResponse, max_bytes: int
159
+ ) -> bytes:
160
+ received = 0
161
+ parts: list[bytes] = []
162
+ async for chunk in response.content.iter_chunked(65_536):
163
+ if received >= max_bytes:
164
+ break
165
+ remaining = max_bytes - received
166
+ if len(chunk) <= remaining:
167
+ received += len(chunk)
168
+ parts.append(chunk)
169
+ else:
170
+ parts.append(chunk[:remaining])
171
+ break
172
+ return b"".join(parts)
173
+
174
+
175
+ async def _drain_aiohttp_body_capped(
176
+ response: aiohttp.ClientResponse, max_bytes: int
177
+ ) -> None:
178
+ if max_bytes <= 0:
179
+ return
180
+ received = 0
181
+ async for chunk in response.content.iter_chunked(65_536):
182
+ received += len(chunk)
183
+ if received >= max_bytes:
184
+ break
185
+
186
+
187
+ async def _run_web_search(query: str) -> list[dict[str, str]]:
188
+ async with (
189
+ httpx.AsyncClient(
190
+ timeout=_REQUEST_TIMEOUT_S,
191
+ follow_redirects=True,
192
+ headers=_WEB_TOOL_HTTP_HEADERS,
193
+ ) as client,
194
+ client.stream(
195
+ "GET",
196
+ "https://lite.duckduckgo.com/lite/",
197
+ params={"q": query},
198
+ ) as response,
199
+ ):
200
+ response.raise_for_status()
201
+ body_bytes = await _read_response_body_capped(
202
+ response, constants._MAX_WEB_FETCH_RESPONSE_BYTES
203
+ )
204
+ text = body_bytes.decode("utf-8", errors="replace")
205
+ parser = SearchResultParser()
206
+ parser.feed(text)
207
+ return parser.results[:_MAX_SEARCH_RESULTS]
208
+
209
+
210
+ async def _run_web_fetch(url: str, egress: WebFetchEgressPolicy) -> dict[str, str]:
211
+ """Fetch URL with manual redirects; each hop is DNS-pinned to validated addresses."""
212
+ current_url = url
213
+ redirect_hops = 0
214
+ timeout = ClientTimeout(total=_REQUEST_TIMEOUT_S)
215
+
216
+ while True:
217
+ addr_infos = await asyncio.to_thread(
218
+ get_validated_stream_addrinfos_for_egress, current_url, egress
219
+ )
220
+ host = urlparse(current_url).hostname or ""
221
+ results = getaddrinfo_rows_to_resolve_results(host, addr_infos)
222
+ resolver = _PinnedEgressStaticResolver(results)
223
+ connector = TCPConnector(
224
+ resolver=resolver,
225
+ force_close=True,
226
+ )
227
+ try:
228
+ async with (
229
+ ClientSession(
230
+ timeout=timeout,
231
+ headers=_WEB_TOOL_HTTP_HEADERS,
232
+ connector=connector,
233
+ ) as session,
234
+ session.get(current_url, allow_redirects=False) as response,
235
+ ):
236
+ if response.status in _WEB_FETCH_REDIRECT_STATUSES:
237
+ await _drain_aiohttp_body_capped(
238
+ response, _REDIRECT_RESPONSE_BODY_CAP_BYTES
239
+ )
240
+ if redirect_hops >= constants._MAX_WEB_FETCH_REDIRECTS:
241
+ raise WebFetchEgressViolation(
242
+ "web_fetch exceeded maximum redirects "
243
+ f"({constants._MAX_WEB_FETCH_REDIRECTS})"
244
+ )
245
+ location = response.headers.get("location")
246
+ if not location or not location.strip():
247
+ raise WebFetchEgressViolation(
248
+ "web_fetch redirect response missing Location header"
249
+ )
250
+ current_url = urljoin(str(response.url), location.strip())
251
+ redirect_hops += 1
252
+ continue
253
+ response.raise_for_status()
254
+ content_type = response.headers.get("content-type", "text/plain")
255
+ final_url = str(response.url)
256
+ encoding = response.get_encoding() or "utf-8"
257
+ body_bytes = await _read_aiohttp_body_capped(
258
+ response, constants._MAX_WEB_FETCH_RESPONSE_BYTES
259
+ )
260
+ finally:
261
+ await connector.close()
262
+
263
+ break
264
+
265
+ text = body_bytes.decode(encoding, errors="replace")
266
+ title = final_url
267
+ data = text
268
+ if "html" in content_type.lower():
269
+ parser = HTMLTextParser()
270
+ parser.feed(text)
271
+ title = parser.title or final_url
272
+ data = "\n".join(parser.text_parts)
273
+ return {
274
+ "url": final_url,
275
+ "title": title,
276
+ "media_type": "text/plain",
277
+ "data": data[:_MAX_FETCH_CHARS],
278
+ }
@@ -0,0 +1,104 @@
1
+ """HTML parsing for web_search / web_fetch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+ from html.parser import HTMLParser
8
+ from typing import Any
9
+ from urllib.parse import parse_qs, unquote, urlparse
10
+
11
+
12
+ class SearchResultParser(HTMLParser):
13
+ """DuckDuckGo lite HTML: extract result links and titles."""
14
+
15
+ def __init__(self) -> None:
16
+ super().__init__()
17
+ self.results: list[dict[str, str]] = []
18
+ self._href: str | None = None
19
+ self._title_parts: list[str] = []
20
+
21
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
22
+ if tag != "a":
23
+ return
24
+ href = dict(attrs).get("href")
25
+ if not href or "uddg=" not in href:
26
+ return
27
+ parsed = urlparse(href)
28
+ query = parse_qs(parsed.query)
29
+ uddg = query.get("uddg", [""])[0]
30
+ if not uddg:
31
+ return
32
+ self._href = unquote(uddg)
33
+ self._title_parts = []
34
+
35
+ def handle_data(self, data: str) -> None:
36
+ if self._href is not None:
37
+ self._title_parts.append(data)
38
+
39
+ def handle_endtag(self, tag: str) -> None:
40
+ if tag != "a" or self._href is None:
41
+ return
42
+ title = " ".join("".join(self._title_parts).split())
43
+ if title and not any(result["url"] == self._href for result in self.results):
44
+ self.results.append({"title": html.unescape(title), "url": self._href})
45
+ self._href = None
46
+ self._title_parts = []
47
+
48
+
49
+ class HTMLTextParser(HTMLParser):
50
+ """Strip scripts/styles and collect visible text + title for fetch previews."""
51
+
52
+ def __init__(self) -> None:
53
+ super().__init__()
54
+ self.title = ""
55
+ self.text_parts: list[str] = []
56
+ self._in_title = False
57
+ self._skip_depth = 0
58
+
59
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
60
+ if tag in {"script", "style", "noscript"}:
61
+ self._skip_depth += 1
62
+ elif tag == "title":
63
+ self._in_title = True
64
+
65
+ def handle_endtag(self, tag: str) -> None:
66
+ if tag in {"script", "style", "noscript"} and self._skip_depth:
67
+ self._skip_depth -= 1
68
+ elif tag == "title":
69
+ self._in_title = False
70
+
71
+ def handle_data(self, data: str) -> None:
72
+ text = " ".join(data.split())
73
+ if not text:
74
+ return
75
+ if self._in_title:
76
+ self.title = f"{self.title} {text}".strip()
77
+ elif not self._skip_depth:
78
+ self.text_parts.append(text)
79
+
80
+
81
+ def content_text(content: Any) -> str:
82
+ if isinstance(content, str):
83
+ return content
84
+ if isinstance(content, list):
85
+ parts = []
86
+ for item in content:
87
+ if isinstance(item, dict):
88
+ parts.append(str(item.get("text", "")))
89
+ else:
90
+ parts.append(str(getattr(item, "text", "")))
91
+ return "\n".join(part for part in parts if part)
92
+ return str(content)
93
+
94
+
95
+ def extract_query(text: str) -> str:
96
+ match = re.search(r"query:\s*(.+)", text, flags=re.IGNORECASE | re.DOTALL)
97
+ if match:
98
+ return match.group(1).strip().strip("\"'")
99
+ return text.strip()
100
+
101
+
102
+ def extract_url(text: str) -> str:
103
+ match = re.search(r"https?://\S+", text)
104
+ return match.group(0).rstrip(").,]") if match else text.strip()
@@ -0,0 +1,87 @@
1
+ """Detect forced Anthropic web server tool requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from api.models.anthropic import MessagesRequest, Tool
6
+
7
+
8
+ def request_text(request: MessagesRequest) -> str:
9
+ """Join all user/assistant message content into one string for tool input parsing."""
10
+ from .parsers import content_text
11
+
12
+ return "\n".join(content_text(message.content) for message in request.messages)
13
+
14
+
15
+ def forced_tool_turn_text(request: MessagesRequest) -> str:
16
+ """Text for parsing forced server-tool inputs: latest user turn only (avoids stale history)."""
17
+ if not request.messages:
18
+ return ""
19
+
20
+ from .parsers import content_text
21
+
22
+ for message in reversed(request.messages):
23
+ if message.role == "user":
24
+ return content_text(message.content)
25
+ return ""
26
+
27
+
28
+ def forced_server_tool_name(request: MessagesRequest) -> str | None:
29
+ """Return web_search or web_fetch only when tool_choice forces that server tool."""
30
+ tc = request.tool_choice
31
+ if not isinstance(tc, dict):
32
+ return None
33
+ if tc.get("type") != "tool":
34
+ return None
35
+ name = tc.get("name")
36
+ if name in {"web_search", "web_fetch"}:
37
+ return str(name)
38
+ return None
39
+
40
+
41
+ def has_tool_named(request: MessagesRequest, name: str) -> bool:
42
+ return any(tool.name == name for tool in request.tools or [])
43
+
44
+
45
+ def is_web_server_tool_request(request: MessagesRequest) -> bool:
46
+ """True when the client forces a web server tool via tool_choice (not merely listed)."""
47
+ forced = forced_server_tool_name(request)
48
+ if forced is None:
49
+ return False
50
+ return has_tool_named(request, forced)
51
+
52
+
53
+ def is_anthropic_server_tool_definition(tool: Tool) -> bool:
54
+ """Whether ``tool`` refers to an Anthropic server tool (web_search / web_fetch family)."""
55
+ name = (tool.name or "").strip()
56
+ if name in ("web_search", "web_fetch"):
57
+ return True
58
+ typ = tool.type
59
+ if isinstance(typ, str):
60
+ return typ.startswith("web_search") or typ.startswith("web_fetch")
61
+ return False
62
+
63
+
64
+ def has_listed_anthropic_server_tools(request: MessagesRequest) -> bool:
65
+ """True when tools include web_search / web_fetch-style entries (listed, forced or not)."""
66
+ return any(is_anthropic_server_tool_definition(t) for t in (request.tools or []))
67
+
68
+
69
+ def openai_chat_upstream_server_tool_error(
70
+ request: MessagesRequest, *, web_tools_enabled: bool
71
+ ) -> str | None:
72
+ """Return a user-facing error when OpenAI Chat upstream cannot satisfy server-tool semantics."""
73
+ forced = forced_server_tool_name(request)
74
+ if forced and not web_tools_enabled:
75
+ return (
76
+ f"tool_choice forces Anthropic server tool {forced!r}, but local web server tools are "
77
+ "disabled (ENABLE_WEB_SERVER_TOOLS=false). Enable them or use a native Anthropic "
78
+ "Messages transport (e.g. open_router, ollama, lmstudio)."
79
+ )
80
+ if not forced and has_listed_anthropic_server_tools(request):
81
+ return (
82
+ "OpenAI Chat upstreams cannot use listed Anthropic server tools "
83
+ "(web_search / web_fetch) without the local web server tool handler. Use a native "
84
+ "Anthropic transport, set ENABLE_WEB_SERVER_TOOLS=true and force the tool with "
85
+ "tool_choice, or remove these tools from the request."
86
+ )
87
+ return None
@@ -0,0 +1,206 @@
1
+ """SSE streaming for local web_search / web_fetch server tool results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import AsyncIterator
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+ from api.models.anthropic import MessagesRequest
11
+ from core.anthropic.server_tool_sse import (
12
+ SERVER_TOOL_USE,
13
+ WEB_FETCH_TOOL_ERROR,
14
+ WEB_FETCH_TOOL_RESULT,
15
+ WEB_SEARCH_TOOL_RESULT,
16
+ WEB_SEARCH_TOOL_RESULT_ERROR,
17
+ )
18
+ from core.anthropic.sse import format_sse_event
19
+
20
+ from . import outbound
21
+ from .constants import _MAX_FETCH_CHARS
22
+ from .egress import WebFetchEgressPolicy
23
+ from .parsers import extract_query, extract_url
24
+ from .request import (
25
+ forced_server_tool_name,
26
+ forced_tool_turn_text,
27
+ has_tool_named,
28
+ )
29
+
30
+
31
+ def _search_summary(query: str, results: list[dict[str, str]]) -> str:
32
+ if not results:
33
+ return f"No web search results found for: {query}"
34
+ lines = [f"Search results for: {query}"]
35
+ for index, result in enumerate(results, start=1):
36
+ lines.append(f"{index}. {result['title']}\n{result['url']}")
37
+ return "\n\n".join(lines)
38
+
39
+
40
+ async def stream_web_server_tool_response(
41
+ request: MessagesRequest,
42
+ input_tokens: int,
43
+ *,
44
+ web_fetch_egress: WebFetchEgressPolicy,
45
+ verbose_client_errors: bool = False,
46
+ ) -> AsyncIterator[str]:
47
+ """Stream a minimal Anthropic-shaped turn for forced `web_search` / `web_fetch` (local fallback).
48
+
49
+ When `ENABLE_WEB_SERVER_TOOLS` is on, this is a proxy-side execution path — not a full
50
+ hosted Anthropic citation or encrypted-content pipeline.
51
+ """
52
+ tool_name = forced_server_tool_name(request)
53
+ if tool_name is None or not has_tool_named(request, tool_name):
54
+ return
55
+
56
+ text = forced_tool_turn_text(request)
57
+ message_id = f"msg_{uuid.uuid4()}"
58
+ tool_id = f"srvtoolu_{uuid.uuid4().hex}"
59
+ usage_key = (
60
+ "web_search_requests" if tool_name == "web_search" else "web_fetch_requests"
61
+ )
62
+ tool_input = (
63
+ {"query": extract_query(text)}
64
+ if tool_name == "web_search"
65
+ else {"url": extract_url(text)}
66
+ )
67
+ _result_block_for_tool = {
68
+ "web_search": WEB_SEARCH_TOOL_RESULT,
69
+ "web_fetch": WEB_FETCH_TOOL_RESULT,
70
+ }
71
+ _error_payload_type_for_tool = {
72
+ "web_search": WEB_SEARCH_TOOL_RESULT_ERROR,
73
+ "web_fetch": WEB_FETCH_TOOL_ERROR,
74
+ }
75
+
76
+ yield format_sse_event(
77
+ "message_start",
78
+ {
79
+ "type": "message_start",
80
+ "message": {
81
+ "id": message_id,
82
+ "type": "message",
83
+ "role": "assistant",
84
+ "content": [],
85
+ "model": request.model,
86
+ "stop_reason": None,
87
+ "stop_sequence": None,
88
+ "usage": {"input_tokens": input_tokens, "output_tokens": 1},
89
+ },
90
+ },
91
+ )
92
+ yield format_sse_event(
93
+ "content_block_start",
94
+ {
95
+ "type": "content_block_start",
96
+ "index": 0,
97
+ "content_block": {
98
+ "type": SERVER_TOOL_USE,
99
+ "id": tool_id,
100
+ "name": tool_name,
101
+ "input": tool_input,
102
+ },
103
+ },
104
+ )
105
+ yield format_sse_event(
106
+ "content_block_stop", {"type": "content_block_stop", "index": 0}
107
+ )
108
+
109
+ try:
110
+ if tool_name == "web_search":
111
+ query = str(tool_input["query"])
112
+ results = await outbound._run_web_search(query)
113
+ result_content: Any = [
114
+ {
115
+ "type": "web_search_result",
116
+ "title": result["title"],
117
+ "url": result["url"],
118
+ }
119
+ for result in results
120
+ ]
121
+ summary = _search_summary(query, results)
122
+ result_block_type = WEB_SEARCH_TOOL_RESULT
123
+ else:
124
+ fetched = await outbound._run_web_fetch(
125
+ str(tool_input["url"]), web_fetch_egress
126
+ )
127
+ result_content = {
128
+ "type": "web_fetch_result",
129
+ "url": fetched["url"],
130
+ "content": {
131
+ "type": "document",
132
+ "source": {
133
+ "type": "text",
134
+ "media_type": fetched["media_type"],
135
+ "data": fetched["data"],
136
+ },
137
+ "title": fetched["title"],
138
+ "citations": {"enabled": True},
139
+ },
140
+ "retrieved_at": datetime.now(UTC).isoformat(),
141
+ }
142
+ summary = fetched["data"][:_MAX_FETCH_CHARS]
143
+ result_block_type = WEB_FETCH_TOOL_RESULT
144
+ except Exception as error:
145
+ fetch_url = str(tool_input["url"]) if tool_name == "web_fetch" else None
146
+ outbound._log_web_tool_failure(tool_name, error, fetch_url=fetch_url)
147
+ result_block_type = _result_block_for_tool[tool_name]
148
+ result_content = {
149
+ "type": _error_payload_type_for_tool[tool_name],
150
+ "error_code": "unavailable",
151
+ }
152
+ summary = outbound._web_tool_client_error_summary(
153
+ tool_name, error, verbose=verbose_client_errors
154
+ )
155
+
156
+ output_tokens = max(1, len(summary) // 4)
157
+
158
+ yield format_sse_event(
159
+ "content_block_start",
160
+ {
161
+ "type": "content_block_start",
162
+ "index": 1,
163
+ "content_block": {
164
+ "type": result_block_type,
165
+ "tool_use_id": tool_id,
166
+ "content": result_content,
167
+ },
168
+ },
169
+ )
170
+ yield format_sse_event(
171
+ "content_block_stop", {"type": "content_block_stop", "index": 1}
172
+ )
173
+ # Model-facing summary: stream as normal text deltas (CLI/transcript code reads `text_delta`,
174
+ # not eager `text` on `content_block_start`).
175
+ yield format_sse_event(
176
+ "content_block_start",
177
+ {
178
+ "type": "content_block_start",
179
+ "index": 2,
180
+ "content_block": {"type": "text", "text": ""},
181
+ },
182
+ )
183
+ yield format_sse_event(
184
+ "content_block_delta",
185
+ {
186
+ "type": "content_block_delta",
187
+ "index": 2,
188
+ "delta": {"type": "text_delta", "text": summary},
189
+ },
190
+ )
191
+ yield format_sse_event(
192
+ "content_block_stop", {"type": "content_block_stop", "index": 2}
193
+ )
194
+ yield format_sse_event(
195
+ "message_delta",
196
+ {
197
+ "type": "message_delta",
198
+ "delta": {"stop_reason": "end_turn", "stop_sequence": None},
199
+ "usage": {
200
+ "input_tokens": input_tokens,
201
+ "output_tokens": output_tokens,
202
+ "server_tool_use": {usage_key: 1},
203
+ },
204
+ },
205
+ )
206
+ yield format_sse_event("message_stop", {"type": "message_stop"})
cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """CLI integration for installed launchers and managed Claude Code."""
2
+
3
+ from .managed import ManagedClaudeSession, ManagedClaudeSessionManager
4
+
5
+ __all__ = ["ManagedClaudeSession", "ManagedClaudeSessionManager"]
cli/claude_env.py ADDED
@@ -0,0 +1,12 @@
1
+ """Shared Claude Code environment policy for FCC client surfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000"
6
+ CLAUDE_NO_AUTH_SENTINEL = "fcc-no-auth"
7
+
8
+
9
+ def claude_auth_token(auth_token: str) -> str:
10
+ """Return the Claude Code auth marker for proxy-auth or no-auth sessions."""
11
+
12
+ return auth_token.strip() or CLAUDE_NO_AUTH_SENTINEL