mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-04-26 10:31:07 +00:00
Some checks are pending
CI / checks (push) Waiting to run
Consolidates the incremental refactor work into a single change set: modular web tools (api/web_tools), native Anthropic request building and SSE block policy, OpenAI conversion and error handling, provider transports and rate limiting, messaging handler and tree queue, safe logging, smoke tests, and broad test coverage.
283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""Application runtime composition and lifecycle ownership."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from fastapi import FastAPI
|
|
from loguru import logger
|
|
|
|
from config.settings import Settings, get_settings
|
|
from providers.registry import ProviderRegistry
|
|
|
|
if TYPE_CHECKING:
|
|
from cli.manager import CLISessionManager
|
|
from messaging.handler import ClaudeMessageHandler
|
|
from messaging.platforms.base import MessagingPlatform
|
|
from messaging.session import SessionStore
|
|
|
|
_SHUTDOWN_TIMEOUT_S = 5.0
|
|
|
|
|
|
async def best_effort(
|
|
name: str,
|
|
awaitable: Any,
|
|
timeout_s: float = _SHUTDOWN_TIMEOUT_S,
|
|
*,
|
|
log_verbose_errors: bool = False,
|
|
) -> None:
|
|
"""Run a shutdown step with timeout; never raise to callers."""
|
|
try:
|
|
await asyncio.wait_for(awaitable, timeout=timeout_s)
|
|
except TimeoutError:
|
|
logger.warning("Shutdown step timed out: {} ({}s)", name, timeout_s)
|
|
except Exception as e:
|
|
if log_verbose_errors:
|
|
logger.warning(
|
|
"Shutdown step failed: {}: {}: {}",
|
|
name,
|
|
type(e).__name__,
|
|
e,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Shutdown step failed: {}: exc_type={}",
|
|
name,
|
|
type(e).__name__,
|
|
)
|
|
|
|
|
|
def warn_if_process_auth_token(settings: Settings) -> None:
|
|
"""Warn when server auth was implicitly inherited from the shell."""
|
|
if settings.uses_process_anthropic_auth_token():
|
|
logger.warning(
|
|
"ANTHROPIC_AUTH_TOKEN is set in the process environment but not in "
|
|
"a configured .env file. The proxy will require that token. Add "
|
|
"ANTHROPIC_AUTH_TOKEN= to .env to disable proxy auth, or set the "
|
|
"same token in .env to make server auth explicit."
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AppRuntime:
|
|
"""Own optional messaging, CLI, session, and provider runtime resources."""
|
|
|
|
app: FastAPI
|
|
settings: Settings
|
|
_provider_registry: ProviderRegistry | None = field(default=None, init=False)
|
|
messaging_platform: MessagingPlatform | None = None
|
|
message_handler: ClaudeMessageHandler | None = None
|
|
cli_manager: CLISessionManager | None = None
|
|
|
|
@classmethod
|
|
def for_app(
|
|
cls,
|
|
app: FastAPI,
|
|
settings: Settings | None = None,
|
|
) -> AppRuntime:
|
|
return cls(app=app, settings=settings or get_settings())
|
|
|
|
async def startup(self) -> None:
|
|
logger.info("Starting Claude Code Proxy...")
|
|
self._provider_registry = ProviderRegistry()
|
|
self.app.state.provider_registry = self._provider_registry
|
|
warn_if_process_auth_token(self.settings)
|
|
await self._start_messaging_if_configured()
|
|
self._publish_state()
|
|
|
|
async def shutdown(self) -> None:
|
|
verbose = self.settings.log_api_error_tracebacks
|
|
if self.message_handler is not None:
|
|
try:
|
|
self.message_handler.session_store.flush_pending_save()
|
|
except Exception as e:
|
|
if verbose:
|
|
logger.warning("Session store flush on shutdown: {}", e)
|
|
else:
|
|
logger.warning(
|
|
"Session store flush on shutdown: exc_type={}",
|
|
type(e).__name__,
|
|
)
|
|
|
|
logger.info("Shutdown requested, cleaning up...")
|
|
if self.messaging_platform:
|
|
await best_effort(
|
|
"messaging_platform.stop",
|
|
self.messaging_platform.stop(),
|
|
log_verbose_errors=verbose,
|
|
)
|
|
if self.cli_manager:
|
|
await best_effort(
|
|
"cli_manager.stop_all",
|
|
self.cli_manager.stop_all(),
|
|
log_verbose_errors=verbose,
|
|
)
|
|
if self._provider_registry is not None:
|
|
await best_effort(
|
|
"provider_registry.cleanup",
|
|
self._provider_registry.cleanup(),
|
|
log_verbose_errors=verbose,
|
|
)
|
|
await self._shutdown_limiter()
|
|
logger.info("Server shut down cleanly")
|
|
|
|
async def _start_messaging_if_configured(self) -> None:
|
|
try:
|
|
from messaging.platforms.factory import (
|
|
MessagingPlatformOptions,
|
|
create_messaging_platform,
|
|
)
|
|
|
|
self.messaging_platform = create_messaging_platform(
|
|
self.settings.messaging_platform,
|
|
MessagingPlatformOptions(
|
|
telegram_bot_token=self.settings.telegram_bot_token,
|
|
allowed_telegram_user_id=self.settings.allowed_telegram_user_id,
|
|
discord_bot_token=self.settings.discord_bot_token,
|
|
allowed_discord_channels=self.settings.allowed_discord_channels,
|
|
voice_note_enabled=self.settings.voice_note_enabled,
|
|
whisper_model=self.settings.whisper_model,
|
|
whisper_device=self.settings.whisper_device,
|
|
hf_token=self.settings.hf_token,
|
|
nvidia_nim_api_key=self.settings.nvidia_nim_api_key,
|
|
messaging_rate_limit=self.settings.messaging_rate_limit,
|
|
messaging_rate_window=self.settings.messaging_rate_window,
|
|
log_raw_messaging_content=self.settings.log_raw_messaging_content,
|
|
log_api_error_tracebacks=self.settings.log_api_error_tracebacks,
|
|
),
|
|
)
|
|
|
|
if self.messaging_platform:
|
|
await self._start_message_handler()
|
|
|
|
except ImportError as e:
|
|
if self.settings.log_api_error_tracebacks:
|
|
logger.warning("Messaging module import error: {}", e)
|
|
else:
|
|
logger.warning(
|
|
"Messaging module import error: exc_type={}",
|
|
type(e).__name__,
|
|
)
|
|
except Exception as e:
|
|
if self.settings.log_api_error_tracebacks:
|
|
logger.error("Failed to start messaging platform: {}", e)
|
|
import traceback
|
|
|
|
logger.error(traceback.format_exc())
|
|
else:
|
|
logger.error(
|
|
"Failed to start messaging platform: exc_type={}",
|
|
type(e).__name__,
|
|
)
|
|
|
|
async def _start_message_handler(self) -> None:
|
|
from cli.manager import CLISessionManager
|
|
from messaging.handler import ClaudeMessageHandler
|
|
from messaging.session import SessionStore
|
|
|
|
workspace = (
|
|
os.path.abspath(self.settings.allowed_dir)
|
|
if self.settings.allowed_dir
|
|
else os.getcwd()
|
|
)
|
|
os.makedirs(workspace, exist_ok=True)
|
|
|
|
data_path = os.path.abspath(self.settings.claude_workspace)
|
|
os.makedirs(data_path, exist_ok=True)
|
|
|
|
api_url = f"http://{self.settings.host}:{self.settings.port}/v1"
|
|
allowed_dirs = [workspace] if self.settings.allowed_dir else []
|
|
plans_dir_abs = os.path.abspath(
|
|
os.path.join(self.settings.claude_workspace, "plans")
|
|
)
|
|
plans_directory = os.path.relpath(plans_dir_abs, workspace)
|
|
self.cli_manager = CLISessionManager(
|
|
workspace_path=workspace,
|
|
api_url=api_url,
|
|
allowed_dirs=allowed_dirs,
|
|
plans_directory=plans_directory,
|
|
claude_bin=self.settings.claude_cli_bin,
|
|
log_raw_cli_diagnostics=self.settings.log_raw_cli_diagnostics,
|
|
log_messaging_error_details=self.settings.log_messaging_error_details,
|
|
)
|
|
|
|
session_store = SessionStore(
|
|
storage_path=os.path.join(data_path, "sessions.json"),
|
|
message_log_cap=self.settings.max_message_log_entries_per_chat,
|
|
)
|
|
platform = self.messaging_platform
|
|
assert platform is not None
|
|
self.message_handler = ClaudeMessageHandler(
|
|
platform=platform,
|
|
cli_manager=self.cli_manager,
|
|
session_store=session_store,
|
|
debug_platform_edits=self.settings.debug_platform_edits,
|
|
debug_subagent_stack=self.settings.debug_subagent_stack,
|
|
log_raw_messaging_content=self.settings.log_raw_messaging_content,
|
|
log_raw_cli_diagnostics=self.settings.log_raw_cli_diagnostics,
|
|
log_messaging_error_details=self.settings.log_messaging_error_details,
|
|
)
|
|
self._restore_tree_state(session_store)
|
|
|
|
platform.on_message(self.message_handler.handle_message)
|
|
await platform.start()
|
|
logger.info(f"{platform.name} platform started with message handler")
|
|
|
|
def _restore_tree_state(self, session_store: SessionStore) -> None:
|
|
saved_trees = session_store.get_all_trees()
|
|
if not saved_trees:
|
|
return
|
|
if self.message_handler is None:
|
|
return
|
|
|
|
logger.info(f"Restoring {len(saved_trees)} conversation trees...")
|
|
from messaging.trees.queue_manager import TreeQueueManager
|
|
|
|
self.message_handler.replace_tree_queue(
|
|
TreeQueueManager.from_dict(
|
|
{
|
|
"trees": saved_trees,
|
|
"node_to_tree": session_store.get_node_mapping(),
|
|
},
|
|
queue_update_callback=self.message_handler.update_queue_positions,
|
|
node_started_callback=self.message_handler.mark_node_processing,
|
|
)
|
|
)
|
|
if self.message_handler.tree_queue.cleanup_stale_nodes() > 0:
|
|
tree_data = self.message_handler.tree_queue.to_dict()
|
|
session_store.sync_from_tree_data(
|
|
tree_data["trees"], tree_data["node_to_tree"]
|
|
)
|
|
|
|
def _publish_state(self) -> None:
|
|
self.app.state.messaging_platform = self.messaging_platform
|
|
self.app.state.message_handler = self.message_handler
|
|
self.app.state.cli_manager = self.cli_manager
|
|
|
|
async def _shutdown_limiter(self) -> None:
|
|
verbose = self.settings.log_api_error_tracebacks
|
|
try:
|
|
from messaging.limiter import MessagingRateLimiter
|
|
except Exception as e:
|
|
if verbose:
|
|
logger.debug(
|
|
"Rate limiter shutdown skipped (import failed): {}: {}",
|
|
type(e).__name__,
|
|
e,
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"Rate limiter shutdown skipped (import failed): exc_type={}",
|
|
type(e).__name__,
|
|
)
|
|
return
|
|
|
|
await best_effort(
|
|
"MessagingRateLimiter.shutdown_instance",
|
|
MessagingRateLimiter.shutdown_instance(),
|
|
timeout_s=2.0,
|
|
log_verbose_errors=verbose,
|
|
)
|