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
api/routes.py ADDED
@@ -0,0 +1,156 @@
1
+ """FastAPI route handlers."""
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
4
+ from loguru import logger
5
+
6
+ from config.settings import Settings
7
+ from core.anthropic import get_token_count
8
+ from core.trace import trace_event
9
+ from providers.registry import ProviderRegistry
10
+
11
+ from . import dependencies
12
+ from .dependencies import get_settings, require_api_key
13
+ from .model_catalog import build_models_list_response
14
+ from .models.anthropic import MessagesRequest, TokenCountRequest
15
+ from .models.openai_responses import OpenAIResponsesRequest
16
+ from .models.responses import ModelsListResponse
17
+ from .request_pipeline import ApiRequestPipeline
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ def get_request_pipeline(
23
+ request: Request,
24
+ settings: Settings = Depends(get_settings),
25
+ ) -> ApiRequestPipeline:
26
+ """Build the API request pipeline for route handlers."""
27
+ return ApiRequestPipeline(
28
+ settings,
29
+ provider_getter=lambda provider_type: dependencies.resolve_provider(
30
+ provider_type, app=request.app, settings=settings
31
+ ),
32
+ token_counter=get_token_count,
33
+ )
34
+
35
+
36
+ def _probe_response(allow: str) -> Response:
37
+ """Return an empty success response for compatibility probes."""
38
+ return Response(status_code=204, headers={"Allow": allow})
39
+
40
+
41
+ # =============================================================================
42
+ # Routes
43
+ # =============================================================================
44
+ @router.post("/v1/messages")
45
+ async def create_message(
46
+ request_data: MessagesRequest,
47
+ pipeline: ApiRequestPipeline = Depends(get_request_pipeline),
48
+ _auth=Depends(require_api_key),
49
+ ):
50
+ """Create a message (always streaming)."""
51
+ return pipeline.create_message(request_data)
52
+
53
+
54
+ @router.api_route("/v1/messages", methods=["HEAD", "OPTIONS"])
55
+ async def probe_messages(_auth=Depends(require_api_key)):
56
+ """Respond to Claude compatibility probes for the messages endpoint."""
57
+ return _probe_response("POST, HEAD, OPTIONS")
58
+
59
+
60
+ @router.post("/v1/responses")
61
+ async def create_response(
62
+ request_data: OpenAIResponsesRequest,
63
+ pipeline: ApiRequestPipeline = Depends(get_request_pipeline),
64
+ _auth=Depends(require_api_key),
65
+ ):
66
+ """Create an OpenAI Responses-compatible response through this proxy."""
67
+ return await pipeline.create_response(request_data)
68
+
69
+
70
+ @router.api_route("/v1/responses", methods=["HEAD", "OPTIONS"])
71
+ async def probe_responses(_auth=Depends(require_api_key)):
72
+ """Respond to OpenAI Responses compatibility probes."""
73
+ return _probe_response("POST, HEAD, OPTIONS")
74
+
75
+
76
+ @router.post("/v1/messages/count_tokens")
77
+ async def count_tokens(
78
+ request_data: TokenCountRequest,
79
+ pipeline: ApiRequestPipeline = Depends(get_request_pipeline),
80
+ _auth=Depends(require_api_key),
81
+ ):
82
+ """Count tokens for a request."""
83
+ return pipeline.count_tokens(request_data)
84
+
85
+
86
+ @router.api_route("/v1/messages/count_tokens", methods=["HEAD", "OPTIONS"])
87
+ async def probe_count_tokens(_auth=Depends(require_api_key)):
88
+ """Respond to Claude compatibility probes for the token count endpoint."""
89
+ return _probe_response("POST, HEAD, OPTIONS")
90
+
91
+
92
+ @router.get("/")
93
+ async def root(
94
+ settings: Settings = Depends(get_settings), _auth=Depends(require_api_key)
95
+ ):
96
+ """Root endpoint."""
97
+ return {
98
+ "status": "ok",
99
+ "provider": settings.provider_type,
100
+ "model": settings.model,
101
+ }
102
+
103
+
104
+ @router.api_route("/", methods=["HEAD", "OPTIONS"])
105
+ async def probe_root():
106
+ """Respond to unauthenticated local compatibility probes for the root endpoint."""
107
+ return _probe_response("GET, HEAD, OPTIONS")
108
+
109
+
110
+ @router.get("/health")
111
+ async def health():
112
+ """Health check endpoint."""
113
+ return {"status": "healthy"}
114
+
115
+
116
+ @router.api_route("/health", methods=["HEAD", "OPTIONS"])
117
+ async def probe_health():
118
+ """Respond to compatibility probes for the health endpoint."""
119
+ return _probe_response("GET, HEAD, OPTIONS")
120
+
121
+
122
+ @router.get("/v1/models", response_model=ModelsListResponse)
123
+ async def list_models(
124
+ request: Request,
125
+ settings: Settings = Depends(get_settings),
126
+ _auth=Depends(require_api_key),
127
+ ):
128
+ """List the model ids this proxy advertises to Claude-compatible clients."""
129
+ trace_event(stage="ingress", event="api.models.list", source="api")
130
+ registry = getattr(request.app.state, "provider_registry", None)
131
+ provider_registry = registry if isinstance(registry, ProviderRegistry) else None
132
+ return build_models_list_response(settings, provider_registry)
133
+
134
+
135
+ @router.post("/stop")
136
+ async def stop_cli(request: Request, _auth=Depends(require_api_key)):
137
+ """Stop all CLI sessions and pending tasks."""
138
+ workflow = getattr(request.app.state, "messaging_workflow", None)
139
+ if not workflow:
140
+ # Fallback if messaging not initialized
141
+ cli_manager = getattr(request.app.state, "cli_manager", None)
142
+ if cli_manager:
143
+ await cli_manager.stop_all()
144
+ logger.info("STOP_CLI: source=cli_manager cancelled_count=N/A")
145
+ return {"status": "stopped", "source": "cli_manager"}
146
+ raise HTTPException(status_code=503, detail="Messaging system not initialized")
147
+
148
+ count = await workflow.stop_all_tasks()
149
+ trace_event(
150
+ stage="ingress",
151
+ event="api.cli.stop_via_messaging_workflow",
152
+ source="api",
153
+ cancelled_nodes=count,
154
+ )
155
+ logger.info("STOP_CLI: source=messaging_workflow cancelled_count={}", count)
156
+ return {"status": "stopped", "cancelled_count": count}
api/runtime.py ADDED
@@ -0,0 +1,334 @@
1
+ """Application runtime composition and lifecycle ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ from dataclasses import dataclass, field
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from fastapi import FastAPI
12
+ from loguru import logger
13
+
14
+ from api.admin_urls import local_admin_url
15
+ from config.settings import Settings, get_settings
16
+ from providers.exceptions import ServiceUnavailableError
17
+ from providers.registry import ProviderRegistry
18
+
19
+ if TYPE_CHECKING:
20
+ from cli.managed import ManagedClaudeSessionManager
21
+ from messaging.platforms.base import MessagingPlatform
22
+ from messaging.session import SessionStore
23
+ from messaging.workflow import MessagingWorkflow
24
+
25
+ _SHUTDOWN_TIMEOUT_S = 5.0
26
+
27
+
28
+ async def best_effort(
29
+ name: str,
30
+ awaitable: Any,
31
+ timeout_s: float = _SHUTDOWN_TIMEOUT_S,
32
+ *,
33
+ log_verbose_errors: bool = False,
34
+ ) -> None:
35
+ """Run a shutdown step with timeout; never raise to callers."""
36
+ try:
37
+ await asyncio.wait_for(awaitable, timeout=timeout_s)
38
+ except TimeoutError:
39
+ logger.warning("Shutdown step timed out: {} ({}s)", name, timeout_s)
40
+ except Exception as e:
41
+ if log_verbose_errors:
42
+ logger.warning(
43
+ "Shutdown step failed: {}: {}: {}",
44
+ name,
45
+ type(e).__name__,
46
+ e,
47
+ )
48
+ else:
49
+ logger.warning(
50
+ "Shutdown step failed: {}: exc_type={}",
51
+ name,
52
+ type(e).__name__,
53
+ )
54
+
55
+
56
+ def warn_if_process_auth_token(settings: Settings) -> None:
57
+ """Warn when server auth was implicitly inherited from the shell."""
58
+ if settings.uses_process_anthropic_auth_token():
59
+ logger.warning(
60
+ "ANTHROPIC_AUTH_TOKEN is set in the process environment but not in "
61
+ "a configured .env file. The proxy will require that token. Add "
62
+ "ANTHROPIC_AUTH_TOKEN= to .env to disable proxy auth, or set the "
63
+ "same token in .env to make server auth explicit."
64
+ )
65
+
66
+
67
+ def log_startup_failure(settings: Settings, exc: Exception) -> None:
68
+ """Log startup failures without traceback noise unless verbose diagnostics are enabled."""
69
+ message = startup_failure_message(settings, exc)
70
+ logger.error("Startup failed:\n{}", message)
71
+
72
+
73
+ def startup_failure_message(settings: Settings, exc: Exception) -> str:
74
+ """Return a concise startup failure message for logs and ASGI lifespan failure."""
75
+ if isinstance(exc, ServiceUnavailableError):
76
+ return exc.message.strip() or "Server startup failed."
77
+
78
+ if settings.log_api_error_tracebacks:
79
+ return f"{type(exc).__name__}: {exc}"
80
+
81
+ return f"Server startup failed: exc_type={type(exc).__name__}"
82
+
83
+
84
+ @dataclass(slots=True)
85
+ class AppRuntime:
86
+ """Own optional messaging, CLI, session, and provider runtime resources."""
87
+
88
+ app: FastAPI
89
+ settings: Settings
90
+ _provider_registry: ProviderRegistry | None = field(default=None, init=False)
91
+ messaging_platform: MessagingPlatform | None = None
92
+ messaging_workflow: MessagingWorkflow | None = None
93
+ cli_manager: ManagedClaudeSessionManager | None = None
94
+
95
+ @classmethod
96
+ def for_app(
97
+ cls,
98
+ app: FastAPI,
99
+ settings: Settings | None = None,
100
+ ) -> AppRuntime:
101
+ return cls(app=app, settings=settings or get_settings())
102
+
103
+ async def startup(self) -> None:
104
+ logger.info("Starting Claude Code Proxy...")
105
+ admin_url = local_admin_url(self.settings)
106
+ self._provider_registry = ProviderRegistry()
107
+ self.app.state.provider_registry = self._provider_registry
108
+ try:
109
+ warn_if_process_auth_token(self.settings)
110
+ await self._validate_configured_models_best_effort()
111
+ self._provider_registry.start_model_list_refresh(self.settings)
112
+ await self._start_messaging_if_configured()
113
+ self._publish_state()
114
+ logging.getLogger("uvicorn.error").info(
115
+ "Admin UI: %s (local-only)", admin_url
116
+ )
117
+ except Exception as exc:
118
+ log_startup_failure(self.settings, exc)
119
+ await best_effort(
120
+ "provider_registry.cleanup",
121
+ self._provider_registry.cleanup(),
122
+ log_verbose_errors=self.settings.log_api_error_tracebacks,
123
+ )
124
+ raise
125
+
126
+ async def _validate_configured_models_best_effort(self) -> None:
127
+ """Warm validation status without blocking first-run/admin access."""
128
+ if self._provider_registry is None:
129
+ return
130
+ try:
131
+ await self._provider_registry.validate_configured_models(self.settings)
132
+ except ServiceUnavailableError as exc:
133
+ self.app.state.startup_validation_error = exc.message
134
+ logger.warning(
135
+ "Configured provider model validation failed during startup; "
136
+ "server will continue and requests will fail at provider resolution "
137
+ "when config is incomplete. {}",
138
+ exc.message,
139
+ )
140
+
141
+ async def shutdown(self) -> None:
142
+ verbose = self.settings.log_api_error_tracebacks
143
+ if self.messaging_workflow is not None:
144
+ try:
145
+ self.messaging_workflow.session_store.flush_pending_save()
146
+ except Exception as e:
147
+ if verbose:
148
+ logger.warning("Session store flush on shutdown: {}", e)
149
+ else:
150
+ logger.warning(
151
+ "Session store flush on shutdown: exc_type={}",
152
+ type(e).__name__,
153
+ )
154
+
155
+ logger.info("Shutdown requested, cleaning up...")
156
+ if self.messaging_platform:
157
+ await best_effort(
158
+ "messaging_platform.stop",
159
+ self.messaging_platform.stop(),
160
+ log_verbose_errors=verbose,
161
+ )
162
+ if self.cli_manager:
163
+ await best_effort(
164
+ "cli_manager.stop_all",
165
+ self.cli_manager.stop_all(),
166
+ log_verbose_errors=verbose,
167
+ )
168
+ if self._provider_registry is not None:
169
+ await best_effort(
170
+ "provider_registry.cleanup",
171
+ self._provider_registry.cleanup(),
172
+ log_verbose_errors=verbose,
173
+ )
174
+ await self._shutdown_limiter()
175
+ logger.info("Server shut down cleanly")
176
+
177
+ async def _start_messaging_if_configured(self) -> None:
178
+ try:
179
+ from messaging.platforms.factory import (
180
+ MessagingPlatformOptions,
181
+ create_messaging_platform,
182
+ )
183
+
184
+ self.messaging_platform = create_messaging_platform(
185
+ self.settings.messaging_platform,
186
+ MessagingPlatformOptions(
187
+ telegram_bot_token=self.settings.telegram_bot_token,
188
+ allowed_telegram_user_id=self.settings.allowed_telegram_user_id,
189
+ discord_bot_token=self.settings.discord_bot_token,
190
+ allowed_discord_channels=self.settings.allowed_discord_channels,
191
+ voice_note_enabled=self.settings.voice_note_enabled,
192
+ whisper_model=self.settings.whisper_model,
193
+ whisper_device=self.settings.whisper_device,
194
+ hf_token=self.settings.hf_token,
195
+ nvidia_nim_api_key=self.settings.nvidia_nim_api_key,
196
+ messaging_rate_limit=self.settings.messaging_rate_limit,
197
+ messaging_rate_window=self.settings.messaging_rate_window,
198
+ log_raw_messaging_content=self.settings.log_raw_messaging_content,
199
+ log_api_error_tracebacks=self.settings.log_api_error_tracebacks,
200
+ ),
201
+ )
202
+
203
+ if self.messaging_platform:
204
+ await self._start_messaging_workflow()
205
+
206
+ except ImportError as e:
207
+ if self.settings.log_api_error_tracebacks:
208
+ logger.warning("Messaging module import error: {}", e)
209
+ else:
210
+ logger.warning(
211
+ "Messaging module import error: exc_type={}",
212
+ type(e).__name__,
213
+ )
214
+ except Exception as e:
215
+ if self.settings.log_api_error_tracebacks:
216
+ logger.error("Failed to start messaging platform: {}", e)
217
+ import traceback
218
+
219
+ logger.error(traceback.format_exc())
220
+ else:
221
+ logger.error(
222
+ "Failed to start messaging platform: exc_type={}",
223
+ type(e).__name__,
224
+ )
225
+
226
+ async def _start_messaging_workflow(self) -> None:
227
+ from cli.managed import ManagedClaudeSessionManager
228
+ from messaging.session import SessionStore
229
+ from messaging.workflow import MessagingWorkflow
230
+
231
+ workspace = (
232
+ os.path.abspath(self.settings.allowed_dir)
233
+ if self.settings.allowed_dir
234
+ else os.getcwd()
235
+ )
236
+ os.makedirs(workspace, exist_ok=True)
237
+
238
+ data_path = os.path.abspath(self.settings.claude_workspace)
239
+ os.makedirs(data_path, exist_ok=True)
240
+
241
+ api_url = f"http://{self.settings.host}:{self.settings.port}/v1"
242
+ allowed_dirs = [workspace] if self.settings.allowed_dir else []
243
+ plans_dir_abs = os.path.abspath(
244
+ os.path.join(self.settings.claude_workspace, "plans")
245
+ )
246
+ plans_directory = os.path.relpath(plans_dir_abs, workspace)
247
+ self.cli_manager = ManagedClaudeSessionManager(
248
+ workspace_path=workspace,
249
+ api_url=api_url,
250
+ allowed_dirs=allowed_dirs,
251
+ plans_directory=plans_directory,
252
+ claude_bin=self.settings.claude_cli_bin,
253
+ auth_token=getattr(self.settings, "anthropic_auth_token", ""),
254
+ log_raw_cli_diagnostics=self.settings.log_raw_cli_diagnostics,
255
+ log_messaging_error_details=self.settings.log_messaging_error_details,
256
+ )
257
+
258
+ session_store = SessionStore(
259
+ storage_path=os.path.join(data_path, "sessions.json"),
260
+ message_log_cap=self.settings.max_message_log_entries_per_chat,
261
+ )
262
+ platform = self.messaging_platform
263
+ assert platform is not None
264
+ self.messaging_workflow = MessagingWorkflow(
265
+ platform=platform,
266
+ cli_manager=self.cli_manager,
267
+ session_store=session_store,
268
+ debug_platform_edits=self.settings.debug_platform_edits,
269
+ debug_subagent_stack=self.settings.debug_subagent_stack,
270
+ log_raw_messaging_content=self.settings.log_raw_messaging_content,
271
+ log_raw_cli_diagnostics=self.settings.log_raw_cli_diagnostics,
272
+ log_messaging_error_details=self.settings.log_messaging_error_details,
273
+ )
274
+ self._restore_tree_state(session_store)
275
+
276
+ platform.on_message(self.messaging_workflow.handle_message)
277
+ await platform.start()
278
+ logger.info(f"{platform.name} platform started with messaging workflow")
279
+
280
+ def _restore_tree_state(self, session_store: SessionStore) -> None:
281
+ saved_trees = session_store.get_all_trees()
282
+ if not saved_trees:
283
+ return
284
+ if self.messaging_workflow is None:
285
+ return
286
+
287
+ logger.info(f"Restoring {len(saved_trees)} conversation trees...")
288
+ from messaging.trees import TreeQueueManager
289
+
290
+ self.messaging_workflow.replace_tree_queue(
291
+ TreeQueueManager.from_dict(
292
+ {
293
+ "trees": saved_trees,
294
+ "node_to_tree": session_store.get_node_mapping(),
295
+ },
296
+ queue_update_callback=self.messaging_workflow.update_queue_positions,
297
+ node_started_callback=self.messaging_workflow.mark_node_processing,
298
+ )
299
+ )
300
+ if self.messaging_workflow.tree_queue.cleanup_stale_nodes() > 0:
301
+ tree_data = self.messaging_workflow.tree_queue.to_dict()
302
+ session_store.sync_from_tree_data(
303
+ tree_data["trees"], tree_data["node_to_tree"]
304
+ )
305
+
306
+ def _publish_state(self) -> None:
307
+ self.app.state.messaging_platform = self.messaging_platform
308
+ self.app.state.messaging_workflow = self.messaging_workflow
309
+ self.app.state.cli_manager = self.cli_manager
310
+
311
+ async def _shutdown_limiter(self) -> None:
312
+ verbose = self.settings.log_api_error_tracebacks
313
+ try:
314
+ from messaging.limiter import MessagingRateLimiter
315
+ except Exception as e:
316
+ if verbose:
317
+ logger.debug(
318
+ "Rate limiter shutdown skipped (import failed): {}: {}",
319
+ type(e).__name__,
320
+ e,
321
+ )
322
+ else:
323
+ logger.debug(
324
+ "Rate limiter shutdown skipped (import failed): exc_type={}",
325
+ type(e).__name__,
326
+ )
327
+ return
328
+
329
+ await best_effort(
330
+ "MessagingRateLimiter.shutdown_instance",
331
+ MessagingRateLimiter.shutdown_instance(),
332
+ timeout_s=2.0,
333
+ log_verbose_errors=verbose,
334
+ )
api/validation_log.py ADDED
@@ -0,0 +1,48 @@
1
+ """Safe metadata summaries for HTTP 422 validation logging (no raw text content)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def summarize_request_validation_body(
9
+ body: Any,
10
+ ) -> tuple[list[dict[str, Any]], list[str]]:
11
+ """Return message shape summary and tool name list for debug logs."""
12
+ messages = body.get("messages") if isinstance(body, dict) else None
13
+ message_summary: list[dict[str, Any]] = []
14
+ if isinstance(messages, list):
15
+ for msg in messages:
16
+ if not isinstance(msg, dict):
17
+ message_summary.append({"message_kind": type(msg).__name__})
18
+ continue
19
+ content = msg.get("content")
20
+ item: dict[str, Any] = {
21
+ "role": msg.get("role"),
22
+ "content_kind": type(content).__name__,
23
+ }
24
+ if isinstance(content, list):
25
+ item["block_types"] = [
26
+ block.get("type", "dict")
27
+ if isinstance(block, dict)
28
+ else type(block).__name__
29
+ for block in content[:12]
30
+ ]
31
+ item["block_keys"] = [
32
+ sorted(str(key) for key in block)[:12]
33
+ for block in content[:5]
34
+ if isinstance(block, dict)
35
+ ]
36
+ elif isinstance(content, str):
37
+ item["content_length"] = len(content)
38
+ message_summary.append(item)
39
+
40
+ tool_names: list[str] = []
41
+ if isinstance(body, dict) and isinstance(body.get("tools"), list):
42
+ tool_names = [
43
+ str(tool.get("name", ""))
44
+ for tool in body["tools"]
45
+ if isinstance(tool, dict)
46
+ ]
47
+
48
+ return message_summary, tool_names
@@ -0,0 +1,22 @@
1
+ """Compatibility re-exports for :mod:`api.web_tools` (web_search / web_fetch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from api.web_tools.egress import (
8
+ WebFetchEgressPolicy,
9
+ WebFetchEgressViolation,
10
+ enforce_web_fetch_egress,
11
+ )
12
+ from api.web_tools.request import is_web_server_tool_request
13
+ from api.web_tools.streaming import stream_web_server_tool_response
14
+
15
+ __all__ = [
16
+ "WebFetchEgressPolicy",
17
+ "WebFetchEgressViolation",
18
+ "enforce_web_fetch_egress",
19
+ "httpx",
20
+ "is_web_server_tool_request",
21
+ "stream_web_server_tool_response",
22
+ ]
@@ -0,0 +1,17 @@
1
+ """Submodules for Anthropic web server tool handling (search/fetch, egress, streaming)."""
2
+
3
+ from .egress import (
4
+ WebFetchEgressPolicy,
5
+ WebFetchEgressViolation,
6
+ enforce_web_fetch_egress,
7
+ )
8
+ from .request import is_web_server_tool_request
9
+ from .streaming import stream_web_server_tool_response
10
+
11
+ __all__ = [
12
+ "WebFetchEgressPolicy",
13
+ "WebFetchEgressViolation",
14
+ "enforce_web_fetch_egress",
15
+ "is_web_server_tool_request",
16
+ "stream_web_server_tool_response",
17
+ ]
@@ -0,0 +1,15 @@
1
+ """Limits and defaults for outbound web server tool HTTP."""
2
+
3
+ _REQUEST_TIMEOUT_S = 20.0
4
+ _MAX_SEARCH_RESULTS = 10
5
+ _MAX_FETCH_CHARS = 24_000
6
+ # Hard cap on raw bytes read from HTTP responses before decode / HTML parse (memory bound).
7
+ _MAX_WEB_FETCH_RESPONSE_BYTES = 2 * 1024 * 1024
8
+ # Drain at most this many bytes from redirect responses before following Location.
9
+ _REDIRECT_RESPONSE_BODY_CAP_BYTES = 65_536
10
+ _MAX_WEB_FETCH_REDIRECTS = 10
11
+ _WEB_FETCH_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
12
+
13
+ _WEB_TOOL_HTTP_HEADERS = {
14
+ "User-Agent": "Mozilla/5.0 compatible; devcopilot/2.0",
15
+ }
@@ -0,0 +1,99 @@
1
+ """Egress policy for user-controlled web_fetch URLs (SSRF guard)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import socket
7
+ from dataclasses import dataclass
8
+ from urllib.parse import urlparse
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class WebFetchEgressPolicy:
13
+ """Egress rules for user-influenced web_fetch URLs."""
14
+
15
+ allow_private_network_targets: bool
16
+ allowed_schemes: frozenset[str]
17
+
18
+
19
+ class WebFetchEgressViolation(ValueError):
20
+ """Raised when a web_fetch URL is rejected by egress policy (SSRF guard)."""
21
+
22
+
23
+ def _port_for_url(parsed) -> int:
24
+ if parsed.port is not None:
25
+ return parsed.port
26
+ return 443 if (parsed.scheme or "").lower() == "https" else 80
27
+
28
+
29
+ def _stream_getaddrinfo_or_raise(host: str, port: int) -> list[tuple]:
30
+ try:
31
+ return socket.getaddrinfo(
32
+ host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP
33
+ )
34
+ except OSError as exc:
35
+ raise WebFetchEgressViolation(
36
+ f"Could not resolve host {host!r}: {exc}"
37
+ ) from exc
38
+
39
+
40
+ def get_validated_stream_addrinfos_for_egress(
41
+ url: str, policy: WebFetchEgressPolicy
42
+ ) -> list[tuple]:
43
+ """Resolve and validate a URL for web_fetch, returning getaddrinfo rows for pinning.
44
+
45
+ Each HTTP connect pins to only these `getaddrinfo` results so a malicious DNS
46
+ server cannot rebind to a disallowed address between resolution and the TCP
47
+ connect (used by :func:`api.web_tools.outbound._run_web_fetch`).
48
+ """
49
+ parsed = urlparse(url)
50
+ scheme = (parsed.scheme or "").lower()
51
+ if scheme not in policy.allowed_schemes:
52
+ raise WebFetchEgressViolation(
53
+ f"URL scheme {scheme!r} is not allowed for web_fetch"
54
+ )
55
+
56
+ host = parsed.hostname
57
+ if host is None or host == "":
58
+ raise WebFetchEgressViolation("web_fetch URL must include a host")
59
+
60
+ port = _port_for_url(parsed)
61
+
62
+ if policy.allow_private_network_targets:
63
+ return _stream_getaddrinfo_or_raise(host, port)
64
+
65
+ host_lower = host.lower()
66
+ if host_lower == "localhost" or host_lower.endswith(".localhost"):
67
+ raise WebFetchEgressViolation("localhost targets are not allowed for web_fetch")
68
+ if host_lower.endswith(".local"):
69
+ raise WebFetchEgressViolation(".local hostnames are not allowed for web_fetch")
70
+
71
+ try:
72
+ parsed_ip = ipaddress.ip_address(host)
73
+ except ValueError:
74
+ parsed_ip = None
75
+
76
+ if parsed_ip is not None:
77
+ if not parsed_ip.is_global:
78
+ raise WebFetchEgressViolation(
79
+ f"Non-public IP host {host!r} is not allowed for web_fetch"
80
+ )
81
+ return _stream_getaddrinfo_or_raise(host, port)
82
+
83
+ infos = _stream_getaddrinfo_or_raise(host, port)
84
+ for *_, sockaddr in infos:
85
+ addr = sockaddr[0]
86
+ try:
87
+ resolved = ipaddress.ip_address(addr)
88
+ except ValueError:
89
+ continue
90
+ if not resolved.is_global:
91
+ raise WebFetchEgressViolation(
92
+ f"Host {host!r} resolves to a non-public address ({resolved})"
93
+ )
94
+ return infos
95
+
96
+
97
+ def enforce_web_fetch_egress(url: str, policy: WebFetchEgressPolicy) -> None:
98
+ """Validate ``url`` (scheme, host, and resolved addresses) for web_fetch."""
99
+ get_validated_stream_addrinfos_for_egress(url, policy)