Team Ai
Apppublic

Jack1808/Claude_Code

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
app.py274 linesDownload Raw Back to api
1"""FastAPI application factory and configuration."""2 3import asyncio4import os5from contextlib import asynccontextmanager6 7from fastapi import FastAPI, HTTPException, Request8from fastapi.responses import JSONResponse9from loguru import logger10 11from config.logging_config import configure_logging12from config.settings import get_settings13from providers.exceptions import ProviderError14 15from .dependencies import cleanup_provider, validate_request_api_key16from .routes import router17 18# Opt-in to future behavior for python-telegram-bot19os.environ["PTB_TIMEDELTA"] = "1"20 21# Configure logging first (before any module logs)22_settings = get_settings()23configure_logging(_settings.log_file)24 25 26_SHUTDOWN_TIMEOUT_S = 5.027 28 29def _normalize_malformed_query_base_url_request(request: Request) -> None:30    """Normalize malformed request targets when base URL contains query auth.31 32    Some clients concatenate paths onto a base URL containing query params as plain33    strings, producing targets like:34      /?psw:token/v1/messages?beta=true35    This rewrites them to:36      /v1/messages?psw:token&beta=true37    """38    if request.scope.get("path") != "/":39        return40 41    raw_query_bytes = request.scope.get("query_string", b"")42    raw_query = raw_query_bytes.decode("utf-8", errors="ignore")43    if not raw_query or "/v1/" not in raw_query:44        return45 46    auth_part, _, remainder = raw_query.partition("/v1/")47    if not auth_part or not remainder:48        return49 50    if "?" in remainder:51        path_suffix, trailing_query = remainder.split("?", 1)52    else:53        path_suffix, trailing_query = remainder, ""54 55    new_path = f"/v1/{path_suffix}"56    new_query = auth_part if not trailing_query else f"{auth_part}&{trailing_query}"57 58    request.scope["path"] = new_path59    request.scope["raw_path"] = new_path.encode("utf-8")60    request.scope["query_string"] = new_query.encode("utf-8")61 62 63async def _best_effort(64    name: str, awaitable, timeout_s: float = _SHUTDOWN_TIMEOUT_S65) -> None:66    """Run a shutdown step with timeout; never raise to callers."""67    try:68        await asyncio.wait_for(awaitable, timeout=timeout_s)69    except TimeoutError:70        logger.warning(f"Shutdown step timed out: {name} ({timeout_s}s)")71    except Exception as e:72        logger.warning(f"Shutdown step failed: {name}: {type(e).__name__}: {e}")73 74 75@asynccontextmanager76async def lifespan(app: FastAPI):77    """Application lifespan manager."""78    settings = get_settings()79    logger.info("Starting Claude Code Proxy...")80 81    # Initialize messaging platform if configured82    messaging_platform = None83    message_handler = None84    cli_manager = None85 86    try:87        # Use the messaging factory to create the right platform88        from messaging.platforms.factory import create_messaging_platform89 90        messaging_platform = create_messaging_platform(91            platform_type=settings.messaging_platform,92            bot_token=settings.telegram_bot_token,93            allowed_user_id=settings.allowed_telegram_user_id,94            discord_bot_token=settings.discord_bot_token,95            allowed_discord_channels=settings.allowed_discord_channels,96        )97 98        if messaging_platform:99            from cli.manager import CLISessionManager100            from messaging.handler import ClaudeMessageHandler101            from messaging.session import SessionStore102 103            # Setup workspace - CLI runs in allowed_dir if set (e.g. project root)104            workspace = (105                os.path.abspath(settings.allowed_dir)106                if settings.allowed_dir107                else os.getcwd()108            )109            os.makedirs(workspace, exist_ok=True)110 111            # Session data stored in agent_workspace112            data_path = os.path.abspath(settings.claude_workspace)113            os.makedirs(data_path, exist_ok=True)114 115            api_url = f"http://{settings.host}:{settings.port}/v1"116            allowed_dirs = [workspace] if settings.allowed_dir else []117            plans_dir_abs = os.path.abspath(118                os.path.join(settings.claude_workspace, "plans")119            )120            plans_directory = os.path.relpath(plans_dir_abs, workspace)121            cli_manager = CLISessionManager(122                workspace_path=workspace,123                api_url=api_url,124                allowed_dirs=allowed_dirs,125                plans_directory=plans_directory,126            )127 128            # Initialize session store129            session_store = SessionStore(130                storage_path=os.path.join(data_path, "sessions.json")131            )132 133            # Create and register message handler134            message_handler = ClaudeMessageHandler(135                platform=messaging_platform,136                cli_manager=cli_manager,137                session_store=session_store,138            )139 140            # Restore tree state if available141            saved_trees = session_store.get_all_trees()142            if saved_trees:143                logger.info(f"Restoring {len(saved_trees)} conversation trees...")144                from messaging.trees.queue_manager import TreeQueueManager145 146                message_handler.replace_tree_queue(147                    TreeQueueManager.from_dict(148                        {149                            "trees": saved_trees,150                            "node_to_tree": session_store.get_node_mapping(),151                        },152                        queue_update_callback=message_handler.update_queue_positions,153                        node_started_callback=message_handler.mark_node_processing,154                    )155                )156                # Reconcile restored state - anything PENDING/IN_PROGRESS is lost across restart157                if message_handler.tree_queue.cleanup_stale_nodes() > 0:158                    # Sync back and save159                    tree_data = message_handler.tree_queue.to_dict()160                    session_store.sync_from_tree_data(161                        tree_data["trees"], tree_data["node_to_tree"]162                    )163 164            # Wire up the handler165            messaging_platform.on_message(message_handler.handle_message)166 167            # Start the platform168            await messaging_platform.start()169            logger.info(170                f"{messaging_platform.name} platform started with message handler"171            )172 173    except ImportError as e:174        logger.warning(f"Messaging module import error: {e}")175    except Exception as e:176        logger.error(f"Failed to start messaging platform: {e}")177        import traceback178 179        logger.error(traceback.format_exc())180 181    # Store in app state for access in routes182    app.state.messaging_platform = messaging_platform183    app.state.message_handler = message_handler184    app.state.cli_manager = cli_manager185 186    yield187 188    # Cleanup189    if message_handler and hasattr(message_handler, "session_store"):190        try:191            message_handler.session_store.flush_pending_save()192        except Exception as e:193            logger.warning(f"Session store flush on shutdown: {e}")194    logger.info("Shutdown requested, cleaning up...")195    if messaging_platform:196        await _best_effort("messaging_platform.stop", messaging_platform.stop())197    if cli_manager:198        await _best_effort("cli_manager.stop_all", cli_manager.stop_all())199    await _best_effort("cleanup_provider", cleanup_provider())200 201    # Ensure background limiter worker doesn't keep the loop alive.202    try:203        from messaging.limiter import MessagingRateLimiter204 205        await _best_effort(206            "MessagingRateLimiter.shutdown_instance",207            MessagingRateLimiter.shutdown_instance(),208            timeout_s=2.0,209        )210    except Exception:211        # Limiter may never have been imported/initialized.212        pass213 214    logger.info("Server shut down cleanly")215 216 217def create_app() -> FastAPI:218    """Create and configure the FastAPI application."""219    app = FastAPI(220        title="Claude Code Proxy",221        version="2.0.0",222        lifespan=lifespan,223    )224 225    @app.middleware("http")226    async def enforce_api_key(request: Request, call_next):227        """Enforce API key for every request before routing/method matching."""228        _normalize_malformed_query_base_url_request(request)229        try:230            validate_request_api_key(request, get_settings())231        except HTTPException as exc:232            return JSONResponse(233                status_code=exc.status_code,234                content={"detail": exc.detail},235            )236        return await call_next(request)237 238    # Register routes239    app.include_router(router)240 241    # Exception handlers242    @app.exception_handler(ProviderError)243    async def provider_error_handler(request: Request, exc: ProviderError):244        """Handle provider-specific errors and return Anthropic format."""245        logger.error(f"Provider Error: {exc.error_type} - {exc.message}")246        return JSONResponse(247            status_code=exc.status_code,248            content=exc.to_anthropic_format(),249        )250 251    @app.exception_handler(Exception)252    async def general_error_handler(request: Request, exc: Exception):253        """Handle general errors and return Anthropic format."""254        logger.error(f"General Error: {exc!s}")255        import traceback256 257        logger.error(traceback.format_exc())258        return JSONResponse(259            status_code=500,260            content={261                "type": "error",262                "error": {263                    "type": "api_error",264                    "message": "An unexpected error occurred.",265                },266            },267        )268 269    return app270 271 272# Default app instance for uvicorn273app = create_app()274