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.
- api/__init__.py +17 -0
- api/admin_config.py +1303 -0
- api/admin_routes.py +287 -0
- api/admin_static/admin.css +459 -0
- api/admin_static/admin.js +497 -0
- api/admin_static/index.html +77 -0
- api/admin_urls.py +34 -0
- api/app.py +194 -0
- api/command_utils.py +164 -0
- api/dependencies.py +144 -0
- api/detection.py +152 -0
- api/gateway_model_ids.py +54 -0
- api/model_catalog.py +133 -0
- api/model_router.py +125 -0
- api/models/__init__.py +45 -0
- api/models/anthropic.py +234 -0
- api/models/openai_responses.py +28 -0
- api/models/responses.py +60 -0
- api/optimization_handlers.py +154 -0
- api/request_pipeline.py +424 -0
- api/routes.py +156 -0
- api/runtime.py +334 -0
- api/validation_log.py +48 -0
- api/web_server_tools.py +22 -0
- api/web_tools/__init__.py +17 -0
- api/web_tools/constants.py +15 -0
- api/web_tools/egress.py +99 -0
- api/web_tools/outbound.py +278 -0
- api/web_tools/parsers.py +104 -0
- api/web_tools/request.py +87 -0
- api/web_tools/streaming.py +206 -0
- cli/__init__.py +5 -0
- cli/claude_env.py +12 -0
- cli/entrypoints.py +166 -0
- cli/env.example +209 -0
- cli/launchers/__init__.py +1 -0
- cli/launchers/claude.py +84 -0
- cli/launchers/codex.py +204 -0
- cli/launchers/codex_model_catalog.py +186 -0
- cli/launchers/common.py +93 -0
- cli/managed/__init__.py +6 -0
- cli/managed/claude.py +215 -0
- cli/managed/manager.py +157 -0
- cli/managed/session.py +260 -0
- cli/process_registry.py +78 -0
- config/__init__.py +5 -0
- config/constants.py +13 -0
- config/logging_config.py +159 -0
- config/nim.py +118 -0
- config/paths.py +91 -0
- config/provider_catalog.py +259 -0
- config/provider_ids.py +7 -0
- config/settings.py +538 -0
- core/__init__.py +1 -0
- core/anthropic/__init__.py +46 -0
- core/anthropic/content.py +31 -0
- core/anthropic/conversion.py +587 -0
- core/anthropic/emitted_sse_tracker.py +346 -0
- core/anthropic/errors.py +70 -0
- core/anthropic/native_messages_request.py +280 -0
- core/anthropic/native_sse_block_policy.py +313 -0
- core/anthropic/provider_stream_error.py +34 -0
- core/anthropic/server_tool_sse.py +14 -0
- core/anthropic/sse.py +440 -0
- core/anthropic/stream_contracts.py +205 -0
- core/anthropic/stream_recovery.py +346 -0
- core/anthropic/stream_recovery_session.py +133 -0
- core/anthropic/thinking.py +140 -0
- core/anthropic/tokens.py +117 -0
- core/anthropic/tools.py +212 -0
- core/anthropic/utils.py +9 -0
- core/openai_responses/__init__.py +5 -0
- core/openai_responses/adapter.py +31 -0
- core/openai_responses/anthropic_sse.py +59 -0
- core/openai_responses/errors.py +22 -0
- core/openai_responses/events.py +19 -0
- core/openai_responses/ids.py +21 -0
- core/openai_responses/input.py +258 -0
- core/openai_responses/items.py +37 -0
- core/openai_responses/reasoning.py +52 -0
- core/openai_responses/stream.py +25 -0
- core/openai_responses/stream_state.py +654 -0
- core/openai_responses/tools.py +374 -0
- core/openai_responses/usage.py +37 -0
- core/rate_limit.py +60 -0
- core/trace.py +216 -0
- devcopilot-0.2.0.dist-info/METADATA +687 -0
- devcopilot-0.2.0.dist-info/RECORD +189 -0
- devcopilot-0.2.0.dist-info/WHEEL +4 -0
- devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
- devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
- messaging/__init__.py +26 -0
- messaging/cli_event_constants.py +67 -0
- messaging/command_context.py +66 -0
- messaging/command_dispatcher.py +37 -0
- messaging/commands.py +275 -0
- messaging/event_parser.py +181 -0
- messaging/limiter.py +300 -0
- messaging/models.py +36 -0
- messaging/node_event_pipeline.py +127 -0
- messaging/node_runner.py +342 -0
- messaging/platforms/__init__.py +15 -0
- messaging/platforms/base.py +228 -0
- messaging/platforms/discord.py +567 -0
- messaging/platforms/factory.py +103 -0
- messaging/platforms/outbox.py +144 -0
- messaging/platforms/telegram.py +688 -0
- messaging/platforms/voice_flow.py +295 -0
- messaging/rendering/__init__.py +3 -0
- messaging/rendering/discord_markdown.py +318 -0
- messaging/rendering/markdown_tables.py +49 -0
- messaging/rendering/profiles.py +55 -0
- messaging/rendering/telegram_markdown.py +327 -0
- messaging/safe_diagnostics.py +17 -0
- messaging/session.py +334 -0
- messaging/transcript.py +581 -0
- messaging/transcription.py +164 -0
- messaging/trees/__init__.py +15 -0
- messaging/trees/data.py +482 -0
- messaging/trees/manager.py +433 -0
- messaging/trees/processor.py +179 -0
- messaging/trees/repository.py +177 -0
- messaging/turn_intake.py +235 -0
- messaging/ui_updates.py +101 -0
- messaging/voice.py +76 -0
- messaging/workflow.py +200 -0
- providers/__init__.py +31 -0
- providers/base.py +152 -0
- providers/cerebras/__init__.py +7 -0
- providers/cerebras/client.py +31 -0
- providers/cerebras/request.py +55 -0
- providers/codestral/__init__.py +7 -0
- providers/codestral/client.py +34 -0
- providers/deepseek/__init__.py +11 -0
- providers/deepseek/client.py +51 -0
- providers/deepseek/request.py +475 -0
- providers/defaults.py +41 -0
- providers/error_mapping.py +309 -0
- providers/exceptions.py +113 -0
- providers/fireworks/__init__.py +5 -0
- providers/fireworks/client.py +45 -0
- providers/fireworks/request.py +48 -0
- providers/gemini/__init__.py +7 -0
- providers/gemini/client.py +49 -0
- providers/gemini/request.py +199 -0
- providers/groq/__init__.py +7 -0
- providers/groq/client.py +31 -0
- providers/groq/request.py +83 -0
- providers/kimi/__init__.py +10 -0
- providers/kimi/client.py +53 -0
- providers/kimi/request.py +42 -0
- providers/llamacpp/__init__.py +3 -0
- providers/llamacpp/client.py +16 -0
- providers/lmstudio/__init__.py +5 -0
- providers/lmstudio/client.py +16 -0
- providers/mistral/__init__.py +7 -0
- providers/mistral/client.py +31 -0
- providers/mistral/request.py +37 -0
- providers/model_listing.py +133 -0
- providers/nvidia_nim/__init__.py +7 -0
- providers/nvidia_nim/client.py +91 -0
- providers/nvidia_nim/request.py +430 -0
- providers/nvidia_nim/voice.py +95 -0
- providers/ollama/__init__.py +7 -0
- providers/ollama/client.py +39 -0
- providers/open_router/__init__.py +7 -0
- providers/open_router/client.py +124 -0
- providers/open_router/request.py +42 -0
- providers/opencode/__init__.py +11 -0
- providers/opencode/client.py +31 -0
- providers/opencode/request.py +35 -0
- providers/rate_limit.py +300 -0
- providers/registry.py +527 -0
- providers/transports/__init__.py +1 -0
- providers/transports/anthropic_messages/__init__.py +5 -0
- providers/transports/anthropic_messages/http.py +118 -0
- providers/transports/anthropic_messages/recovery.py +206 -0
- providers/transports/anthropic_messages/stream.py +295 -0
- providers/transports/anthropic_messages/transport.py +236 -0
- providers/transports/openai_chat/__init__.py +5 -0
- providers/transports/openai_chat/recovery.py +217 -0
- providers/transports/openai_chat/stream.py +384 -0
- providers/transports/openai_chat/tool_calls.py +293 -0
- providers/transports/openai_chat/transport.py +156 -0
- providers/wafer/__init__.py +10 -0
- providers/wafer/client.py +50 -0
- providers/zai/__init__.py +10 -0
- providers/zai/client.py +46 -0
- 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
|
+
}
|
api/web_tools/parsers.py
ADDED
|
@@ -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()
|
api/web_tools/request.py
ADDED
|
@@ -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
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
|