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
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
|
api/web_server_tools.py
ADDED
|
@@ -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
|
+
}
|
api/web_tools/egress.py
ADDED
|
@@ -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)
|