diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..69b47b5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.bat text eol=crlf diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ee39169..4cdc3b8 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -37,7 +37,7 @@ jobs: args: > -Dsonar.host.url=https://sonarcloud.io -Dsonar.organization=codingworkflow - -Dsonar.projectKey=codingworkflow_claude-code-a-api + -Dsonar.projectKey=codingworkflow_claude-code-api -Dsonar.python.coverage.reportPaths=dist/quality/coverage/coverage.xml -Dsonar.python.xunit.reportPath=dist/quality/sonar/xunit-report.xml -Dsonar.enableIssueAnnotation=true diff --git a/README.md b/README.md index d010c1b..87061d5 100644 --- a/README.md +++ b/README.md @@ -1,277 +1,102 @@ # Claude Code API Gateway -A simple, focused OpenAI-compatible API gateway for Claude Code with streaming support. -Leverage the Claude Code SDK use mode. Don't hack the token credentials. +OpenAI-compatible API gateway for Claude Code CLI. -## Getting Started +## What You Get -Use the Makefile to install the project or pip/uv. +- OpenAI-style endpoints (`/v1/chat/completions`, `/v1/models`, sessions/projects APIs) +- Streaming and non-streaming chat completions +- Claude model aliases and fallback behavior +- Optional `model` field: if omitted, CLI default model is used -![API Started](assets/api.png) +## Quick Start (Linux/macOS) -![Cline use](assets/cline.png) - -![Cursor](assets/cursor.png) - -![OpenWebUI](assets/openwebui.png) - -![Roo Code config](assets/roocode.png) - -![Roo Code chat](assets/roo_code.png) - -### Python Implementation ```bash -# Clone and setup git clone https://github.com/codingworkflow/claude-code-api cd claude-code-api - -# Install dependencies & module -make install - -# Start the API server +make install make start ``` -## Limitations - -- There might be a limit on maximum input below normal "Sonnet 4" input as Claude Code usually doesn't ingest more than 25k tokens (despite the context being 100k). -- Claude Code auto-compacts context beyond 100k. -- Currently runs with bypass mode to avoid tool errors. -- Claude Code tools may need to be disabled to avoid overlap and background usage. -- Runs only on Linux/Mac as Claude Code doesn't run on Windows (you can use WSL). -- Note that Claude Code will default to accessing the current workspace environment/folder and is set to use bypass mode. - - -## Features - -- **Claude-Only Models**: Supports exactly the 4 Claude models that Claude Code CLI offers -- **OpenAI Compatible**: Drop-in replacement for OpenAI API endpoints -- **Streaming Support**: Real-time streaming responses -- **Simple & Clean**: No over-engineering, focused implementation -- **Claude Code Integration**: Leverages Claude Code CLI with streaming output - -## Supported Models - -Model definitions live in `claude_code_api/config/models.json`. -Override with `CLAUDE_CODE_API_MODELS_PATH` to point at a custom JSON file. - -- `claude-opus-4-5-20250929` - Claude Opus 4.5 (Most powerful) -- `claude-sonnet-4-5-20250929` - Claude Sonnet 4.5 (Latest Sonnet) -- `claude-haiku-4-5-20250929` - Claude Haiku 4.5 (Fast & cost-effective) - -## Quick Start +Server URLs: +- API: `http://localhost:8000` +- OpenAPI docs: `http://localhost:8000/docs` +- Health: `http://localhost:8000/health` -### Prerequisites -- Python 3.10+ -- Claude Code CLI installed and accessible -- Valid Anthropic API key configured in Claude Code (ensure it works in current directory src/) +## Quick Start (Windows) -### Installation & Setup +Use the provided wrappers: -```bash -# Clone and setup -git clone https://github.com/codingworkflow/claude-code-api -cd claude-code-api - -# Install dependencies -make install - -# Run tests to verify setup -make test - -# Start the API server -make start-dev +```bat +make.bat install +start.bat ``` -The API will be available at: -- **API**: http://localhost:8000 -- **Docs**: http://localhost:8000/docs -- **Health**: http://localhost:8000/health +Notes: +- `start.bat` starts the API in dev mode. +- `make.bat` provides common project commands. +- Claude Code CLI support on Windows may require WSL depending on your local setup. -## Makefile Commands - -### Core Commands -```bash -make install # Install production dependencies -make install-dev # Install development dependencies -make test # Run all tests -make start # Start API server (production) -make start-dev # Start API server (development with reload) -``` +## Supported Models -### Testing -```bash -make test # Run all tests -make test-fast # Run tests (skip slow ones) -make test-hello # Test hello world with Haiku -make test-health # Test health check only -make test-models # Test models API only -make test-chat # Test chat completions only -make quick-test # Quick validation of core functionality -``` +Model config is in `claude_code_api/config/models.json`. +Override with `CLAUDE_CODE_API_MODELS_PATH`. -### Development -```bash -make dev-setup # Complete development setup -make lint # Run linting checks -make format # Format code with black/isort -make type-check # Run type checking -make clean # Clean up cache files -``` +- `claude-opus-4-6-20260205` +- `claude-opus-4-5-20251101` +- `claude-sonnet-4-5-20250929` +- `claude-haiku-4-5-20251001` -### Information -```bash -make help # Show all available commands -make models # Show supported Claude models -make info # Show project information -make check-claude # Check if Claude Code CLI is available -``` +Alias/fallback behavior: +- `model` is optional in `/v1/chat/completions`. +- `opus`, `claude-opus-latest`, `claude-opus-4-6` resolve to Opus 4.6. +- If Opus 4.6 is rejected at runtime, gateway retries once with latest configured Opus 4.5. +- If all attempted models are rejected, API returns `400` with `error.code = model_not_supported`. ## API Usage -### Chat Completions +Chat completion: ```bash curl -X POST http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "model": "claude-sonnet-4-5-20250929", "messages": [ - {"role": "user", "content": "Hello!"} + {"role": "user", "content": "Hello"} ] }' ``` -### List Models +List models: ```bash curl http://localhost:8000/v1/models ``` -### Streaming Chat +Streaming: ```bash curl -X POST http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-5-20250929", - "messages": [ - {"role": "user", "content": "Tell me a joke"} - ], + "messages": [{"role": "user", "content": "Tell me a joke"}], "stream": true }' ``` -## Project Structure - -``` -claude-code-api/ -├── claude_code_api/ -│ ├── main.py # FastAPI application -│ ├── api/ # API endpoints -│ │ ├── chat.py # Chat completions -│ │ ├── models.py # Models API -│ │ ├── projects.py # Project management -│ │ └── sessions.py # Session management -│ ├── core/ # Core functionality -│ │ ├── auth.py # Authentication -│ │ ├── claude_manager.py # Claude Code integration -│ │ ├── session_manager.py # Session management -│ │ ├── config.py # Configuration -│ │ └── database.py # Database layer -│ ├── models/ # Data models -│ │ ├── claude.py # Claude-specific models -│ │ └── openai.py # OpenAI-compatible models -│ ├── utils/ # Utilities -│ │ ├── streaming.py # Streaming support -│ │ └── parser.py # Output parsing -│ └── tests/ # Test suite -├── Makefile # Development commands -├── pyproject.toml # Project configuration -├── setup.py # Package setup -└── README.md # This file -``` - -## Testing - -The test suite validates: -- Health check endpoints -- Models API (Claude models only) -- Chat completions with Haiku model -- Hello world functionality -- OpenAI compatibility (structure) -- Error handling - -Run specific test suites: -```bash -make test-hello # Test hello world with Haiku -make test-models # Test models API -make test-chat # Test chat completions -``` - -## Development - -### Setup Development Environment -```bash -make dev-setup -``` - -### Code Quality -```bash -make format # Format code -make lint # Check linting -make type-check # Type checking -``` - -### Quick Validation -```bash -make quick-test # Test core functionality -``` - -## Deployment - -### Check Deployment Readiness -```bash -make deploy-check -``` - -### Production Server -```bash -make start-prod # Start with multiple workers -``` -Use http://127.0.0.1:8000/v1 as OpenAPI endpoint - ## Configuration -Key settings in `claude_code_api/core/config.py`: -- `claude_binary_path`: Path to Claude Code CLI -- `project_root`: Root directory for projects -- `database_url`: Database connection string -- `require_auth`: Enable/disable authentication - -## Design Principles - -1. **Simple & Focused**: No over-engineering -2. **Claude-Only**: Pure Claude gateway, no OpenAI models -3. **Streaming First**: Built for real-time streaming -4. **OpenAI Compatible**: Drop-in API compatibility -5. **Test-Driven**: Comprehensive test coverage +Common settings are in `claude_code_api/core/config.py`: +- `claude_binary_path` +- `project_root` +- `database_url` +- `require_auth` -## Health Check +## Developer Docs -```bash -curl http://localhost:8000/health -``` - -Response: -```json -{ - "status": "healthy", - "version": "1.0.0", - "claude_version": "1.x.x", - "active_sessions": 0 -} -``` +For engineering workflows and internal commands: +- `docs/dev.md` ## License diff --git a/claude_code_api/api/chat.py b/claude_code_api/api/chat.py index 521c403..5f41d99 100644 --- a/claude_code_api/api/chat.py +++ b/claude_code_api/api/chat.py @@ -2,16 +2,20 @@ import hashlib import json -from typing import Any, Dict, Tuple +from typing import Any, Dict, Optional, Tuple import structlog from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import StreamingResponse from pydantic import ValidationError -from claude_code_api.core.claude_manager import create_project_directory +from claude_code_api.core.claude_manager import ( + ClaudeModelNotSupportedError, + ClaudeSessionConflictError, + create_project_directory, +) from claude_code_api.core.session_manager import SessionManager -from claude_code_api.models.claude import validate_claude_model +from claude_code_api.models.claude import get_default_model, validate_claude_model from claude_code_api.models.openai import ( ChatCompletionRequest, ChatCompletionResponse, @@ -116,8 +120,8 @@ async def _resolve_session( session_manager: SessionManager, request: ChatCompletionRequest, project_id: str, - claude_model: str, - system_prompt: str, + claude_model: Optional[str], + system_prompt: Optional[str], ) -> str: if request.session_id: session_id = request.session_id @@ -291,8 +295,12 @@ async def create_chat_completion(request: ChatCompletionRequest, req: Request) - ) try: - # Validate model - claude_model = validate_claude_model(request.model) + requested_model = (request.model or "").strip() or None + # Normalize only when user explicitly requested a model. + claude_model = ( + validate_claude_model(requested_model) if requested_model else None + ) + response_model = claude_model or get_default_model() user_prompt, system_prompt = _extract_prompts(request) @@ -323,6 +331,31 @@ def _register_cli_session(cli_session_id: str): system_prompt=system_prompt, on_cli_session_id=_register_cli_session, ) + except ClaudeSessionConflictError as e: + logger.warning( + "Session already has an active Claude process", + session_id=session_id, + error=str(e), + ) + raise _http_error( + status.HTTP_409_CONFLICT, + "The session is currently busy with another process.", + "invalid_request_error", + "session_busy", + ) from e + except ClaudeModelNotSupportedError as e: + logger.warning( + "Claude rejected requested model", + session_id=session_id, + model=claude_model, + error=str(e), + ) + raise _http_error( + status.HTTP_400_BAD_REQUEST, + "The requested model is not supported.", + "invalid_request_error", + "model_not_supported", + ) from e except Exception as e: logger.error( "Failed to create Claude session", session_id=session_id, error=str(e) @@ -348,7 +381,7 @@ def _register_cli_session(cli_session_id: str): # Handle streaming vs non-streaming if request.stream: return StreamingResponse( - create_sse_response(api_session_id, claude_model, claude_process), + create_sse_response(api_session_id, response_model, claude_process), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", @@ -363,7 +396,7 @@ def _register_cli_session(cli_session_id: str): claude_process=claude_process, session_manager=session_manager, session_id=api_session_id, - model=claude_model, + model=response_model, project_id=project_id, ) diff --git a/claude_code_api/config/models.json b/claude_code_api/config/models.json index c6d1f28..bc770a2 100644 --- a/claude_code_api/config/models.json +++ b/claude_code_api/config/models.json @@ -1,6 +1,25 @@ { "default_model": "claude-sonnet-4-5-20250929", + "aliases": { + "opus": "claude-opus-4-6-20260205", + "sonnet": "claude-sonnet-4-5-20250929", + "haiku": "claude-haiku-4-5-20251001", + "claude-opus-latest": "claude-opus-4-6-20260205", + "claude-opus-4-6": "claude-opus-4-6-20260205", + "claude-opus-4-5": "claude-opus-4-5-20251101", + "claude-opus-4-5-latest": "claude-opus-4-5-20251101" + }, "models": [ + { + "id": "claude-opus-4-6-20260205", + "name": "Claude Opus 4.6", + "description": "Latest Opus model with improved reasoning and coding", + "max_tokens": 65536, + "input_cost_per_1k": 0.005, + "output_cost_per_1k": 0.025, + "supports_streaming": true, + "supports_tools": true + }, { "id": "claude-opus-4-5-20251101", "name": "Claude Opus 4.5", diff --git a/claude_code_api/core/claude_manager.py b/claude_code_api/core/claude_manager.py index 65bed3a..66330e4 100644 --- a/claude_code_api/core/claude_manager.py +++ b/claude_code_api/core/claude_manager.py @@ -4,11 +4,12 @@ import json import os import subprocess +from collections import deque from typing import Any, AsyncGenerator, Callable, Dict, List, Optional import structlog -from claude_code_api.models.claude import get_default_model +from claude_code_api.models.claude import get_available_models, get_default_model from .config import settings from .security import ensure_directory_within_base @@ -37,11 +38,17 @@ def __init__( self._error_task: Optional[asyncio.Task] = None self._on_cli_session_id = on_cli_session_id self._on_end = on_end + self.last_error: Optional[str] = None + self._stderr_tail: deque[str] = deque(maxlen=20) async def start( - self, prompt: str, model: str = None, system_prompt: str = None + self, + prompt: str, + model: Optional[str] = None, + system_prompt: Optional[str] = None, ) -> bool: """Start Claude Code process and wait for completion.""" + self.last_error = None try: # Prepare real command - using exact format from working Claudia example cmd = [settings.claude_binary_path] @@ -102,9 +109,16 @@ async def start( self._output_task = asyncio.create_task(self._read_output()) self._error_task = asyncio.create_task(self._read_error()) + started = await self._verify_startup() + if not started: + await self.stop() + return False + return True except Exception as e: + self.last_error = str(e) + await self.stop() logger.error( "Failed to start Claude process", session_id=self.session_id, @@ -172,10 +186,46 @@ async def _read_error(self): error_text = line.decode().strip() if error_text: + self._stderr_tail.append(error_text) + self.last_error = error_text logger.warning("Claude stderr", message=error_text) except Exception as e: logger.error("Error reading stderr", error=str(e)) + async def _verify_startup(self) -> bool: + """Detect early process failures so API can return actionable errors.""" + if not self.process: + self.last_error = "Claude process was not initialized" + return False + + loop = asyncio.get_running_loop() + deadline = loop.time() + 1.5 + while loop.time() < deadline: + return_code = self.process.returncode + if return_code is None: + await asyncio.sleep(0.05) + continue + + if return_code == 0: + return True + + error_text = self._compose_process_error(return_code) + self.last_error = error_text + logger.error( + "Claude process exited during startup", + session_id=self.session_id, + return_code=return_code, + error=error_text, + ) + return False + + return True + + def _compose_process_error(self, return_code: int) -> str: + if self._stderr_tail: + return f"Claude exited with code {return_code}: {' | '.join(self._stderr_tail)}" + return f"Claude exited with code {return_code}" + async def get_output(self) -> AsyncGenerator[Dict[str, Any], None]: """Get output from Claude process.""" while True: @@ -257,6 +307,42 @@ class ClaudeProcessStartError(ClaudeManagerError): """Raised when a Claude process fails to start.""" +class ClaudeSessionConflictError(ClaudeManagerError): + """Raised when a session already has an active Claude process.""" + + +class ClaudeModelNotSupportedError(ClaudeManagerError): + """Raised when Claude rejects a requested model.""" + + +def _is_model_rejection_error(error_message: str) -> bool: + lowered = (error_message or "").lower() + patterns = ( + "invalid model", + "unknown model", + "model not found", + "unsupported model", + "not support model", + "not a valid model", + ) + return any(pattern in lowered for pattern in patterns) + + +def _resolve_opus_45_fallback(model_id: Optional[str]) -> Optional[str]: + if not model_id or not model_id.startswith("claude-opus-4-6-"): + return None + + opus_45_models = sorted( + model.id + for model in get_available_models() + if model.id.startswith("claude-opus-4-5-") + ) + if not opus_45_models: + return None + + return opus_45_models[-1] + + class ClaudeManager: """Manages multiple Claude Code processes.""" @@ -264,6 +350,7 @@ def __init__(self): self.processes: Dict[str, ClaudeProcess] = {} self.cli_session_index: Dict[str, str] = {} self.max_concurrent = settings.max_concurrent_sessions + self._session_lock = asyncio.Lock() async def get_version(self) -> str: """Get Claude Code version.""" @@ -292,58 +379,159 @@ async def get_version(self) -> str: f"Failed to get Claude version: {str(exc)}" ) from exc - async def create_session( - self, - session_id: str, - project_path: str, - prompt: str, - model: str = None, - system_prompt: str = None, - on_cli_session_id: Optional[Callable[[str], None]] = None, - ) -> ClaudeProcess: - """Create new Claude session.""" - # Check concurrent session limit + def _ensure_session_capacity(self, session_id: str) -> None: + existing_process = self.processes.get(session_id) + if existing_process and existing_process.is_running: + raise ClaudeSessionConflictError( + f"Session {session_id} already has an active Claude process" + ) + if existing_process and not existing_process.is_running: + self._cleanup_process(existing_process) + if len(self.processes) >= self.max_concurrent: raise ClaudeConcurrencyError( f"Maximum concurrent sessions ({self.max_concurrent}) reached" ) - # Ensure project directory exists - os.makedirs(project_path, exist_ok=True) + def _build_model_candidates(self, model: Optional[str]) -> List[Optional[str]]: + candidates: List[Optional[str]] = [model] + fallback_model = _resolve_opus_45_fallback(model) + if fallback_model and fallback_model not in candidates: + candidates.append(fallback_model) + return candidates - # Create process + def _create_process( + self, + session_id: str, + project_path: str, + on_cli_session_id: Optional[Callable[[str], None]], + ) -> ClaudeProcess: def _handle_cli_session_id(cli_session_id: str): self._register_cli_session(session_id, cli_session_id) if on_cli_session_id: on_cli_session_id(cli_session_id) - process = ClaudeProcess( + return ClaudeProcess( session_id=session_id, project_path=project_path, on_cli_session_id=_handle_cli_session_id, on_end=self._cleanup_process, ) - # Start process - success = await process.start( - prompt=prompt, - model=model or get_default_model(), - system_prompt=system_prompt, + def _raise_model_not_supported( + self, + selected_model: Optional[str], + model_candidates: List[Optional[str]], + last_error: str, + ) -> None: + available = ", ".join(model.id for model in get_available_models()) + raise ClaudeModelNotSupportedError( + f"Claude rejected model '{selected_model or ''}'. " + f"Attempted: {', '.join(str(m) for m in model_candidates)}. " + f"Configured models: {available}. " + f"Details: {last_error}" ) - if not success: - raise ClaudeProcessStartError("Failed to start Claude process") + async def _start_with_fallback_models( + self, + session_id: str, + project_path: str, + prompt: str, + selected_model: Optional[str], + system_prompt: Optional[str], + on_cli_session_id: Optional[Callable[[str], None]], + ) -> ClaudeProcess: + model_candidates = self._build_model_candidates(selected_model) + last_error = "Failed to start Claude process" + + for idx, candidate_model in enumerate(model_candidates): + process = self._create_process( + session_id=session_id, + project_path=project_path, + on_cli_session_id=on_cli_session_id, + ) + success = await process.start( + prompt=prompt, + model=candidate_model, + system_prompt=system_prompt, + ) - self.processes[session_id] = process + if success: + self.processes[session_id] = process + if idx > 0: + logger.warning( + "Model fallback activated after rejection", + requested_model=selected_model or "", + fallback_model=candidate_model, + session_id=session_id, + ) + logger.info( + "Claude session created", + session_id=process.session_id, + active_sessions=len(self.processes), + ) + return process + + last_error = process.last_error or last_error + if not _is_model_rejection_error(last_error): + raise ClaudeProcessStartError(last_error) + + has_next_candidate = idx + 1 < len(model_candidates) + if has_next_candidate: + logger.warning( + "Claude rejected model, retrying with fallback", + rejected_model=candidate_model, + fallback_model=model_candidates[idx + 1], + error=last_error, + ) + continue + + self._raise_model_not_supported( + selected_model=selected_model, + model_candidates=model_candidates, + last_error=last_error, + ) + + raise ClaudeProcessStartError(last_error) + + async def create_session( + self, + session_id: str, + project_path: str, + prompt: str, + model: Optional[str] = None, + system_prompt: Optional[str] = None, + on_cli_session_id: Optional[Callable[[str], None]] = None, + ) -> ClaudeProcess: + """Create new Claude session.""" + async with self._session_lock: + self._ensure_session_capacity(session_id) + await asyncio.to_thread(os.makedirs, project_path, exist_ok=True) + + return await self._start_with_fallback_models( + session_id=session_id, + project_path=project_path, + prompt=prompt, + selected_model=model, + system_prompt=system_prompt, + on_cli_session_id=on_cli_session_id, + ) + + async def _stop_session_locked(self, session_id: str) -> None: + resolved_id = self._resolve_session_id(session_id) + if not resolved_id or resolved_id not in self.processes: + return + + process = self.processes[resolved_id] + await process.stop() + self._cleanup_process(process) logger.info( - "Claude session created", - session_id=process.session_id, + "Claude session stopped", + session_id=resolved_id, active_sessions=len(self.processes), ) - return process - def get_session(self, session_id: str) -> Optional[ClaudeProcess]: """Get existing Claude session.""" resolved_id = self._resolve_session_id(session_id) @@ -353,22 +541,14 @@ def get_session(self, session_id: str) -> Optional[ClaudeProcess]: async def stop_session(self, session_id: str): """Stop Claude session.""" - resolved_id = self._resolve_session_id(session_id) - if resolved_id and resolved_id in self.processes: - process = self.processes[resolved_id] - await process.stop() - self._cleanup_process(process) - - logger.info( - "Claude session stopped", - session_id=resolved_id, - active_sessions=len(self.processes), - ) + async with self._session_lock: + await self._stop_session_locked(session_id) async def cleanup_all(self): """Stop all Claude sessions.""" - for session_id in tuple(self.processes): - await self.stop_session(session_id) + async with self._session_lock: + for session_id in tuple(self.processes): + await self._stop_session_locked(session_id) logger.info("All Claude sessions cleaned up") diff --git a/claude_code_api/core/config.py b/claude_code_api/core/config.py index 27772c0..6c0b31e 100644 --- a/claude_code_api/core/config.py +++ b/claude_code_api/core/config.py @@ -64,6 +64,11 @@ def default_session_map_path() -> str: return os.path.join(os.getcwd(), "claude_sessions", "session_map.json") +def default_log_file_path() -> str: + """Default path for application logs.""" + return os.path.join(os.getcwd(), "dist", "logs", "claude-code-api.log") + + def _is_shell_script_line(line: str) -> bool: if not line: return False @@ -157,6 +162,12 @@ def parse_api_keys(cls, v): # Logging Configuration log_level: str = "INFO" log_format: str = "json" + log_file_path: str = default_log_file_path() + log_to_file: bool = True + log_max_bytes: int = 10 * 1024 * 1024 + log_backup_count: int = 5 + log_to_console: bool = True + log_min_level_when_not_debug: str = "WARNING" # CORS Configuration allowed_origins: List[str] = Field( diff --git a/claude_code_api/core/logging_config.py b/claude_code_api/core/logging_config.py new file mode 100644 index 0000000..c121235 --- /dev/null +++ b/claude_code_api/core/logging_config.py @@ -0,0 +1,163 @@ +"""Centralized logging configuration.""" + +import logging +import os +import sys +from logging.handlers import RotatingFileHandler +from typing import Any + +import structlog + +_LIFECYCLE_EVENTS = { + "Starting Claude Code API Gateway", + "Database initialized", + "Managers initialized", + "Claude Code available", + "Shutting down Claude Code API Gateway", + "Shutdown complete", +} +_DEFAULT_LOG_LEVEL = logging.INFO +_DEFAULT_MIN_NON_DEBUG_LEVEL = logging.WARNING +_DEFAULT_MAX_BYTES = 10 * 1024 * 1024 +_DEFAULT_BACKUP_COUNT = 5 +_METHOD_LEVELS = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "warn": logging.WARNING, + "error": logging.ERROR, + "critical": logging.CRITICAL, + "exception": logging.ERROR, +} + + +def _coerce_log_level(level_name: str | None, debug_enabled: bool) -> int: + if debug_enabled: + return logging.DEBUG + if not level_name: + return _DEFAULT_LOG_LEVEL + return getattr(logging, str(level_name).upper(), _DEFAULT_LOG_LEVEL) + + +def _ensure_parent_dir(file_path: str) -> None: + parent_dir = os.path.dirname(file_path) + if parent_dir: + os.makedirs(parent_dir, exist_ok=True) + + +def _create_file_handler( + file_path: str, max_bytes: int, backup_count: int +) -> logging.Handler: + _ensure_parent_dir(file_path) + handler = RotatingFileHandler( + filename=file_path, + maxBytes=max_bytes, + backupCount=backup_count, + encoding="utf-8", + ) + if os.path.exists(file_path) and os.path.getsize(file_path) > max_bytes: + handler.doRollover() + return handler + + +def _minimal_event_filter(debug_enabled: bool, min_level_name: str | None): + if debug_enabled: + return None + + min_level = getattr( + logging, str(min_level_name or "").upper(), _DEFAULT_MIN_NON_DEBUG_LEVEL + ) + + def _processor( + logger: Any, method_name: str, event_dict: dict[str, Any] + ) -> dict[str, Any]: + level = _METHOD_LEVELS.get(method_name.lower(), logging.INFO) + if level >= min_level: + return event_dict + if event_dict.get("lifecycle") is True: + return event_dict + if event_dict.get("event") in _LIFECYCLE_EVENTS: + return event_dict + raise structlog.DropEvent + + return _processor + + +def _build_processors( + debug_enabled: bool, log_format: str, min_level_name: str | None +) -> list[Any]: + processors: list[Any] = [structlog.stdlib.filter_by_level] + minimal_filter = _minimal_event_filter(debug_enabled, min_level_name) + if minimal_filter: + processors.append(minimal_filter) + + processors.extend( + [ + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + ] + ) + + if str(log_format).lower() == "json": + processors.append(structlog.processors.JSONRenderer()) + else: + processors.append(structlog.dev.ConsoleRenderer()) + + return processors + + +def configure_logging(settings: Any) -> None: + """Configure structured logging once for the whole application.""" + debug_enabled = bool(getattr(settings, "debug", False)) + log_level = _coerce_log_level(getattr(settings, "log_level", None), debug_enabled) + log_format = getattr(settings, "log_format", "json") + log_file_path = getattr(settings, "log_file_path", "dist/logs/claude-code-api.log") + log_to_file = bool(getattr(settings, "log_to_file", True)) + log_max_bytes = int(getattr(settings, "log_max_bytes", _DEFAULT_MAX_BYTES)) + log_backup_count = int(getattr(settings, "log_backup_count", _DEFAULT_BACKUP_COUNT)) + log_to_console = bool(getattr(settings, "log_to_console", True)) + log_min_level = getattr(settings, "log_min_level_when_not_debug", "WARNING") + + handlers: list[logging.Handler] = [] + if log_to_file and log_file_path: + try: + handlers.append( + _create_file_handler(log_file_path, log_max_bytes, log_backup_count) + ) + except OSError as exc: + print( + f"Failed to initialize log file handler for {log_file_path}: {exc}. " + "Continuing with console logging only.", + file=sys.stderr, + ) + + if log_to_console or not handlers: + handlers.append(logging.StreamHandler()) + + formatter = logging.Formatter("%(message)s") + for handler in handlers: + handler.setLevel(log_level) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.setLevel(log_level) + root_logger.handlers.clear() + for handler in handlers: + root_logger.addHandler(handler) + + if not debug_enabled: + logging.getLogger("uvicorn.access").setLevel(logging.ERROR) + logging.getLogger("uvicorn.error").setLevel(logging.ERROR) + + structlog.configure( + processors=_build_processors(debug_enabled, log_format, log_min_level), + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) diff --git a/claude_code_api/core/security.py b/claude_code_api/core/security.py index 7ea799d..4b60937 100644 --- a/claude_code_api/core/security.py +++ b/claude_code_api/core/security.py @@ -8,6 +8,8 @@ logger = structlog.get_logger() +PATH_TRAVERSAL_MSG = "Invalid path: Path traversal detected" + def _bad_request(detail: str) -> HTTPException: return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) @@ -42,7 +44,7 @@ def _ensure_within_base(path_value: str, base_path: str, resolved_path: str) -> resolved_path=resolved_path, base_path=abs_base_path, ) - raise _bad_request("Invalid path: Path traversal detected") + raise _bad_request(PATH_TRAVERSAL_MSG) def resolve_path_within_base(path: str, base_path: str) -> str: @@ -79,7 +81,7 @@ def resolve_path_within_base(path: str, base_path: str) -> str: if normalized_path == ".." or normalized_path.startswith(f"..{os.path.sep}"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid path: Path traversal detected", + detail=PATH_TRAVERSAL_MSG, ) if os.path.isabs(normalized_path): resolved_path = os.path.realpath(normalized_path) @@ -100,7 +102,7 @@ def resolve_path_within_base(path: str, base_path: str) -> str: ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid path: Path traversal detected", + detail=PATH_TRAVERSAL_MSG, ) return resolved_path diff --git a/claude_code_api/core/session_manager.py b/claude_code_api/core/session_manager.py index 7c302aa..01f17c0 100644 --- a/claude_code_api/core/session_manager.py +++ b/claude_code_api/core/session_manager.py @@ -3,8 +3,10 @@ import asyncio import json import os +import tempfile import uuid from datetime import timedelta +from threading import Lock from typing import Any, Dict, List, Optional import structlog @@ -16,6 +18,14 @@ logger = structlog.get_logger() +fcntl: Any | None +try: + import fcntl as _fcntl +except ImportError: # pragma: no cover - non-POSIX platforms + fcntl = None +else: + fcntl = _fcntl + class SessionInfo: """Session information and metadata.""" @@ -43,6 +53,7 @@ def __init__(self): self.active_sessions: Dict[str, SessionInfo] = {} self.cli_session_index: Dict[str, str] = {} self.session_map_path = settings.session_map_path + self._persist_lock = Lock() self.cleanup_task: Optional[asyncio.Task] = None self._shutdown_event = asyncio.Event() self._start_cleanup_task() @@ -93,14 +104,41 @@ def _persist_cli_session_map(self): if not self.session_map_path: return try: - directory = os.path.dirname(self.session_map_path) - if directory: - os.makedirs(directory, exist_ok=True) - tmp_path = f"{self.session_map_path}.tmp" + directory = os.path.dirname(self.session_map_path) or os.getcwd() + os.makedirs(directory, exist_ok=True) payload = {"cli_to_api": self.cli_session_index} - with open(tmp_path, "w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2, sort_keys=True) - os.replace(tmp_path, self.session_map_path) + + with self._persist_lock: + lock_path = f"{self.session_map_path}.lock" + with open(lock_path, "a+", encoding="utf-8") as lock_handle: + lock_acquired = False + if fcntl: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) + lock_acquired = True + + tmp_path = None + fd, tmp_path = tempfile.mkstemp( + prefix="session_map_", + suffix=".tmp", + dir=directory, + ) + + try: + try: + handle = os.fdopen(fd, "w", encoding="utf-8") + except Exception: + os.close(fd) + raise + with handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, self.session_map_path) + finally: + if lock_acquired: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN) + if tmp_path and os.path.exists(tmp_path): + os.remove(tmp_path) except Exception as exc: logger.warning("Failed to persist session map", error=str(exc)) diff --git a/claude_code_api/main.py b/claude_code_api/main.py index 85dd8f4..8cb6468 100644 --- a/claude_code_api/main.py +++ b/claude_code_api/main.py @@ -22,49 +22,33 @@ from claude_code_api.core.claude_manager import ClaudeManager from claude_code_api.core.config import settings from claude_code_api.core.database import close_database, create_tables +from claude_code_api.core.logging_config import configure_logging from claude_code_api.core.session_manager import SessionManager from claude_code_api.models.openai import ChatCompletionChunk -# Configure structured logging -structlog.configure( - processors=[ - structlog.stdlib.filter_by_level, - structlog.stdlib.add_logger_name, - structlog.stdlib.add_log_level, - structlog.stdlib.PositionalArgumentsFormatter(), - structlog.processors.TimeStamper(fmt="iso"), - structlog.processors.StackInfoRenderer(), - structlog.processors.format_exc_info, - structlog.processors.UnicodeDecoder(), - structlog.processors.JSONRenderer(), - ], - context_class=dict, - logger_factory=structlog.stdlib.LoggerFactory(), - wrapper_class=structlog.stdlib.BoundLogger, - cache_logger_on_first_use=True, -) - logger = structlog.get_logger() @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan manager.""" - logger.info("Starting Claude Code API Gateway", version="1.0.0") + # Configure logging during startup so import-time failures don't abort module load. + configure_logging(settings) + logger.info("Starting Claude Code API Gateway", version="1.0.0", lifecycle=True) # Initialize database await create_tables() - logger.info("Database initialized") + logger.info("Database initialized", lifecycle=True) # Initialize managers app.state.session_manager = SessionManager() app.state.claude_manager = ClaudeManager() - logger.info("Managers initialized") + logger.info("Managers initialized", lifecycle=True) # Verify Claude Code availability try: claude_version = await app.state.claude_manager.get_version() - logger.info("Claude Code available", version=claude_version) + logger.info("Claude Code available", version=claude_version, lifecycle=True) except Exception as e: logger.error("Claude Code not available", error=str(e)) raise HTTPException( @@ -75,10 +59,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: yield # Cleanup - logger.info("Shutting down Claude Code API Gateway") + logger.info("Shutting down Claude Code API Gateway", lifecycle=True) await app.state.session_manager.cleanup_all() await close_database() - logger.info("Shutdown complete") + logger.info("Shutdown complete", lifecycle=True) app = FastAPI( @@ -148,7 +132,11 @@ async def http_exception_handler(request, exc): @app.exception_handler(RequestValidationError) async def validation_exception_handler(request, exc): """Return OpenAI-style errors for validation failures.""" - status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + status_code = getattr( + status, + "HTTP_422_UNPROCESSABLE_CONTENT", + status.HTTP_422_UNPROCESSABLE_ENTITY, + ) for error in exc.errors(): if error.get("type") in {"value_error.jsondecode", "json_invalid"}: status_code = status.HTTP_400_BAD_REQUEST @@ -237,8 +225,8 @@ async def root(): uvicorn.run( "claude_code_api.main:app", - host="0.0.0.0", - port=8000, + host=settings.host, + port=settings.port, reload=True, - log_level="info", + log_level=settings.log_level.lower(), ) diff --git a/claude_code_api/models/claude.py b/claude_code_api/models/claude.py index b2d5fd2..17f5bbb 100644 --- a/claude_code_api/models/claude.py +++ b/claude_code_api/models/claude.py @@ -2,6 +2,7 @@ import json import os +import re from datetime import datetime from enum import Enum from functools import lru_cache @@ -231,6 +232,9 @@ class ClaudeModelInfo(BaseModel): MODELS_CONFIG_ENV = "CLAUDE_CODE_API_MODELS_PATH" DEFAULT_MODELS_PATH = Path(__file__).resolve().parents[1] / "config" / "models.json" +MODEL_ID_PATTERN = re.compile( + r"^claude-(?P[a-z]+)-(?P\d+)-(?P\d+)(?:-(?P\d+))?$" +) def _models_config_path() -> Path: @@ -264,12 +268,95 @@ def _model_index() -> Dict[str, ClaudeModelInfo]: return model_map +def _config_alias_pairs(raw_aliases: Any) -> List[tuple[str, str]]: + if not isinstance(raw_aliases, dict): + return [] + return [ + (alias, target) + for alias, target in raw_aliases.items() + if isinstance(alias, str) and isinstance(target, str) + ] + + +def _entry_alias_pairs(entry: Any) -> List[tuple[str, str]]: + if not isinstance(entry, dict): + return [] + + model_id = entry.get("id") + model_aliases = entry.get("aliases", []) + if not isinstance(model_id, str) or not isinstance(model_aliases, list): + return [] + + return [(alias, model_id) for alias in model_aliases if isinstance(alias, str)] + + +def _model_alias_index() -> Dict[str, str]: + config = _load_models_config() + aliases = dict(_config_alias_pairs(config.get("aliases", {}))) + for entry in config.get("models", []): + aliases.update(_entry_alias_pairs(entry)) + return aliases + + +def _parse_model_id(model_id: str) -> Optional[tuple[str, int, int, int]]: + match = MODEL_ID_PATTERN.match(model_id) + if not match: + return None + + stamp = match.group("stamp") + return ( + match.group("tier"), + int(match.group("major")), + int(match.group("minor")), + int(stamp) if stamp else 0, + ) + + +def _latest_model_for_tier(tier: str) -> Optional[str]: + ranked_models = [] + for model_id in _model_index(): + parsed = _parse_model_id(model_id) + if not parsed: + continue + parsed_tier, major, minor, stamp = parsed + if parsed_tier != tier: + continue + ranked_models.append((major, minor, stamp, model_id)) + + if not ranked_models: + return None + + ranked_models.sort() + return ranked_models[-1][3] + + +def _resolve_alias(model: str) -> Optional[str]: + model_map = _model_index() + target = _model_alias_index().get(model) + if target and target in model_map: + return target + return None + + # Utility functions for model validation def validate_claude_model(model: str) -> str: """Validate and normalize Claude model name.""" model_map = _model_index() - if model in model_map: - return model + normalized = (model or "").strip() + + if normalized in model_map: + return normalized + + aliased = _resolve_alias(normalized) + if aliased: + return aliased + + parsed = _parse_model_id(normalized) + if parsed and parsed[0] == "opus": + latest_opus = _latest_model_for_tier("opus") + if latest_opus: + return latest_opus + return get_default_model() @@ -288,9 +375,8 @@ def get_default_model() -> str: def get_model_info(model_id: str) -> ClaudeModelInfo: """Get information about a Claude model.""" model_map = _model_index() - if model_id in model_map: - return model_map[model_id] - return model_map[get_default_model()] + resolved_model_id = validate_claude_model(model_id) + return model_map[resolved_model_id] def get_available_models() -> List[ClaudeModelInfo]: diff --git a/claude_code_api/models/openai.py b/claude_code_api/models/openai.py index 5cb04fc..f538096 100644 --- a/claude_code_api/models/openai.py +++ b/claude_code_api/models/openai.py @@ -113,7 +113,9 @@ def get_text_content(self) -> str: class ChatCompletionRequest(BaseModel): """Chat completion request model.""" - model: str = Field(..., description="ID of the model to use") + model: Optional[str] = Field( + None, description="ID of the model to use (optional; CLI default when omitted)" + ) messages: List[ChatMessage] = Field( ..., description="List of messages comprising the conversation" ) diff --git a/claude_code_api/utils/streaming.py b/claude_code_api/utils/streaming.py index 6f55ced..1a8db00 100644 --- a/claude_code_api/utils/streaming.py +++ b/claude_code_api/utils/streaming.py @@ -223,12 +223,9 @@ async def _send_heartbeats( self, session_id: str, heartbeat_queue: asyncio.Queue[Optional[str]] ): """Send periodic heartbeats to keep connection alive.""" - try: - while session_id in self.active_streams: - await asyncio.sleep(self.heartbeat_interval) - await heartbeat_queue.put(SSEFormatter.format_heartbeat()) - except asyncio.CancelledError: - raise + while session_id in self.active_streams: + await asyncio.sleep(self.heartbeat_interval) + await heartbeat_queue.put(SSEFormatter.format_heartbeat()) def get_active_stream_count(self) -> int: """Get number of active streams.""" diff --git a/docs/dev.md b/docs/dev.md new file mode 100644 index 0000000..1f19203 --- /dev/null +++ b/docs/dev.md @@ -0,0 +1,94 @@ +# Development Guide + +This document contains developer workflows (setup, QA, linting, testing, Sonar). + +## Prerequisites + +- Python 3.11+ +- Claude Code CLI available in your shell (`claude --version`) +- GNU Make (Linux/macOS) or `make.bat` (Windows) + +## Setup + +Linux/macOS: + +```bash +make install-dev +``` + +Windows: + +```bat +make.bat install-dev +``` + +## Core Commands + +Linux/macOS (`Makefile`): + +```bash +make install +make install-dev +make start +make start-prod +make test +make test-no-cov +make fmt +make lint +make vet +``` + +Windows (`make.bat`): + +```bat +make.bat install +make.bat install-dev +make.bat start +make.bat start-prod +make.bat test +make.bat test-no-cov +make.bat fmt +make.bat lint +``` + +## QA / Validation + +Lint and format: + +```bash +make fmt +make lint +``` + +Run tests: + +```bash +make test +``` + +## SonarQube + +Generate coverage + run Sonar scan: + +```bash +make sonar +``` + +Coverage-only artifacts for Sonar: + +```bash +make coverage-sonar +``` + +## Logging + +- Logging is configured centrally in `claude_code_api/core/logging_config.py`. +- Default log file: `dist/logs/claude-code-api.log`. +- Rotation is enabled via `log_max_bytes` and `log_backup_count` settings. +- Default runtime behavior logs startup/shutdown lifecycle and errors only. +- Set `debug=true` for extended logging. + +## Windows Notes + +- `start.bat` is a convenience wrapper for `make.bat start`. +- If Claude CLI is unavailable on native Windows in your environment, run the project in WSL. diff --git a/make.bat b/make.bat new file mode 100644 index 0000000..fbc8148 --- /dev/null +++ b/make.bat @@ -0,0 +1,97 @@ +@echo off +setlocal enabledelayedexpansion + +if "%~1"=="" goto :help + +set "TARGET=%~1" +shift +set "EXTRA_ARGS=" +:collect_args +if "%~1"=="" goto :args_done +set "EXTRA_ARGS=!EXTRA_ARGS! %~1" +shift +goto :collect_args +:args_done + +if /I "%TARGET%"=="help" goto :help + +call :find_python +if errorlevel 1 exit /b 1 + +if /I "%TARGET%"=="install" goto :install +if /I "%TARGET%"=="install-dev" goto :install_dev +if /I "%TARGET%"=="start" goto :start +if /I "%TARGET%"=="start-prod" goto :start_prod +if /I "%TARGET%"=="test" goto :test +if /I "%TARGET%"=="test-no-cov" goto :test_no_cov +if /I "%TARGET%"=="fmt" goto :fmt +if /I "%TARGET%"=="lint" goto :lint + +echo [ERROR] Unknown target: %TARGET% +goto :help + +:find_python +set "PYTHON_CMD=python" +where python >nul 2>nul +if %ERRORLEVEL% EQU 0 goto :eof + +where py >nul 2>nul +if %ERRORLEVEL% EQU 0 ( + set "PYTHON_CMD=py -3" + goto :eof +) + +echo [ERROR] Python not found in PATH. +exit /b 1 + +:install +%PYTHON_CMD% -m pip install -e . +exit /b %ERRORLEVEL% + +:install_dev +%PYTHON_CMD% -m pip install -e ".[test,dev]" +exit /b %ERRORLEVEL% + +:start +%PYTHON_CMD% -m uvicorn claude_code_api.main:app --host 0.0.0.0 --port 8000 --reload --reload-exclude=*.db* --reload-exclude=*.log +exit /b %ERRORLEVEL% + +:start_prod +%PYTHON_CMD% -m uvicorn claude_code_api.main:app --host 0.0.0.0 --port 8000 +exit /b %ERRORLEVEL% + +:test +%PYTHON_CMD% -m pytest --cov=claude_code_api --cov-report=html tests/ -v %EXTRA_ARGS% +exit /b %ERRORLEVEL% + +:test_no_cov +%PYTHON_CMD% -m pytest tests/ -v %EXTRA_ARGS% +exit /b %ERRORLEVEL% + +:fmt +%PYTHON_CMD% -m black claude_code_api tests +exit /b %ERRORLEVEL% + +:lint +%PYTHON_CMD% -m flake8 claude_code_api tests +if %ERRORLEVEL% NEQ 0 exit /b %ERRORLEVEL% +%PYTHON_CMD% -m isort --check-only claude_code_api tests +exit /b %ERRORLEVEL% + +:help +echo Claude Code API - Windows helper commands +echo. +echo Usage: +echo make.bat ^ +echo. +echo Targets: +echo install +echo install-dev +echo start +echo start-prod +echo test +echo test-no-cov +echo fmt +echo lint +echo help +exit /b 0 diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..5f7d0c2 --- /dev/null +++ b/start.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +call "%~dp0make.bat" start %* +exit /b %ERRORLEVEL% diff --git a/tests/test_claude_manager_unit.py b/tests/test_claude_manager_unit.py index b4a517f..8648fd5 100644 --- a/tests/test_claude_manager_unit.py +++ b/tests/test_claude_manager_unit.py @@ -1,6 +1,9 @@ """Unit tests for Claude manager helpers.""" import os +import types + +import pytest from claude_code_api.core import claude_manager as cm from claude_code_api.core.config import settings @@ -46,3 +49,194 @@ def test_decode_output_line(): data = process._decode_output_line(b"not-json\n") assert data["type"] == "text" + + +@pytest.mark.asyncio +async def test_create_session_rejects_duplicate_active_session(monkeypatch, tmp_path): + manager = cm.ClaudeManager() + + async def fake_start(self, prompt, model=None, system_prompt=None): + self.is_running = True + return True + + monkeypatch.setattr(cm.ClaudeProcess, "start", fake_start) + + await manager.create_session( + session_id="sess-dup", + project_path=str(tmp_path), + prompt="first prompt", + model="claude-sonnet-4-5-20250929", + ) + + with pytest.raises(cm.ClaudeSessionConflictError): + await manager.create_session( + session_id="sess-dup", + project_path=str(tmp_path), + prompt="second prompt", + model="claude-sonnet-4-5-20250929", + ) + + await manager.cleanup_all() + + +@pytest.mark.asyncio +async def test_create_session_replaces_stale_process(monkeypatch, tmp_path): + manager = cm.ClaudeManager() + + async def fake_start(self, prompt, model=None, system_prompt=None): + self.is_running = True + return True + + monkeypatch.setattr(cm.ClaudeProcess, "start", fake_start) + + first_process = await manager.create_session( + session_id="sess-stale", + project_path=str(tmp_path), + prompt="first prompt", + model="claude-sonnet-4-5-20250929", + ) + first_process.is_running = False + + second_process = await manager.create_session( + session_id="sess-stale", + project_path=str(tmp_path), + prompt="second prompt", + model="claude-sonnet-4-5-20250929", + ) + + assert second_process is not first_process + assert manager.get_session("sess-stale") is second_process + + await manager.cleanup_all() + + +@pytest.mark.asyncio +async def test_create_session_retries_opus_45_when_opus_46_rejected( + monkeypatch, tmp_path +): + manager = cm.ClaudeManager() + attempted_models = [] + + monkeypatch.setattr( + cm, + "get_available_models", + lambda: [ + types.SimpleNamespace(id="claude-opus-4-5-20251101"), + types.SimpleNamespace(id="claude-opus-4-6-20260205"), + ], + ) + + async def fake_start(self, prompt, model=None, system_prompt=None): + attempted_models.append(model) + if model == "claude-opus-4-6-20260205": + self.last_error = "invalid model: claude-opus-4-6-20260205" + self.is_running = False + return False + self.is_running = True + return True + + monkeypatch.setattr(cm.ClaudeProcess, "start", fake_start) + + process = await manager.create_session( + session_id="sess-fallback", + project_path=str(tmp_path), + prompt="prompt", + model="claude-opus-4-6-20260205", + ) + + assert process is not None + assert attempted_models == [ + "claude-opus-4-6-20260205", + "claude-opus-4-5-20251101", + ] + + await manager.cleanup_all() + + +@pytest.mark.asyncio +async def test_create_session_raises_when_model_rejected_without_fallback( + monkeypatch, tmp_path +): + manager = cm.ClaudeManager() + + monkeypatch.setattr( + cm, + "get_available_models", + lambda: [types.SimpleNamespace(id="claude-sonnet-4-5-20250929")], + ) + + async def fake_start(self, prompt, model=None, system_prompt=None): + self.last_error = "unsupported model" + self.is_running = False + return False + + monkeypatch.setattr(cm.ClaudeProcess, "start", fake_start) + + with pytest.raises(cm.ClaudeModelNotSupportedError): + await manager.create_session( + session_id="sess-model-error", + project_path=str(tmp_path), + prompt="prompt", + model="claude-opus-4-6-20260205", + ) + + await manager.cleanup_all() + + +@pytest.mark.asyncio +async def test_create_session_raises_for_non_model_start_failure_without_fallback( + monkeypatch, tmp_path +): + manager = cm.ClaudeManager() + attempted_models = [] + + monkeypatch.setattr( + cm, + "get_available_models", + lambda: [types.SimpleNamespace(id="claude-opus-4-5-20251101")], + ) + + async def fake_start(self, prompt, model=None, system_prompt=None): + attempted_models.append(model) + self.last_error = "failed to spawn process" + self.is_running = False + return False + + monkeypatch.setattr(cm.ClaudeProcess, "start", fake_start) + + with pytest.raises(cm.ClaudeProcessStartError): + await manager.create_session( + session_id="sess-process-error", + project_path=str(tmp_path), + prompt="prompt", + model="claude-opus-4-6-20260205", + ) + + assert attempted_models == ["claude-opus-4-6-20260205"] + await manager.cleanup_all() + + +@pytest.mark.asyncio +async def test_create_session_without_model_does_not_force_model_flag( + monkeypatch, tmp_path +): + manager = cm.ClaudeManager() + attempted_models = [] + + async def fake_start(self, prompt, model=None, system_prompt=None): + attempted_models.append(model) + self.is_running = True + return True + + monkeypatch.setattr(cm.ClaudeProcess, "start", fake_start) + + await manager.create_session( + session_id="sess-no-model", + project_path=str(tmp_path), + prompt="prompt", + model=None, + ) + + assert attempted_models == [None] + + await manager.cleanup_all() diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index c61200e..4a43044 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -20,6 +20,10 @@ from fastapi.testclient import TestClient from httpx import AsyncClient +from claude_code_api.core.claude_manager import ( + ClaudeModelNotSupportedError, + ClaudeSessionConflictError, +) from claude_code_api.core.config import settings from claude_code_api.core.session_manager import SessionManager from claude_code_api.main import app @@ -258,6 +262,19 @@ def test_chat_completion_with_invalid_model_fallback(self, client): # Should work with fallback to default model assert response.status_code in [200, 503] # 503 if Claude not available + def test_chat_completion_without_model_uses_cli_default_behavior(self, client): + """Omitting model should still work and avoid requiring model in request body.""" + request_data = { + "messages": [{"role": "user", "content": "What's 2+2?"}], + "stream": False, + } + + response = client.post("/v1/chat/completions", json=request_data) + assert response.status_code in [200, 503] + if response.status_code == 200: + body = response.json() + assert body["model"] == DEFAULT_MODEL + def test_chat_completion_streaming(self, client): """Test streaming chat completion.""" request_data = { @@ -374,6 +391,48 @@ def test_chat_completion_invalid_model(self, client): # Should still work as model gets converted to default assert response.status_code in [200, 503] # 503 if Claude not available + def test_chat_completion_returns_conflict_for_busy_session( + self, client, monkeypatch + ): + """Return HTTP 409 when an API session already has an active process.""" + claude_manager = client.app.state.claude_manager + + async def fake_create_session(*args, **kwargs): + raise ClaudeSessionConflictError("Session busy") + + monkeypatch.setattr(claude_manager, "create_session", fake_create_session) + + request_data = { + "model": DEFAULT_MODEL, + "messages": [{"role": "user", "content": "Hi"}], + "stream": False, + } + + response = client.post("/v1/chat/completions", json=request_data) + assert response.status_code == 409 + data = response.json() + assert data["error"]["code"] == "session_busy" + + def test_chat_completion_returns_model_not_supported(self, client, monkeypatch): + """Return HTTP 400 when Claude rejects the requested model.""" + claude_manager = client.app.state.claude_manager + + async def fake_create_session(*args, **kwargs): + raise ClaudeModelNotSupportedError("Unsupported model details") + + monkeypatch.setattr(claude_manager, "create_session", fake_create_session) + + request_data = { + "model": DEFAULT_MODEL, + "messages": [{"role": "user", "content": "Hi"}], + "stream": False, + } + + response = client.post("/v1/chat/completions", json=request_data) + assert response.status_code == 400 + data = response.json() + assert data["error"]["code"] == "model_not_supported" + class TestConversationFlow: """Test conversation flow and session management.""" @@ -412,6 +471,7 @@ def test_conversation_continuity(self, client): response_2 = client.post("/v1/chat/completions", json=request_data_2) assert response_2.status_code in [ 200, + 409, 404, 503, ] # May fail if session management incomplete @@ -541,21 +601,18 @@ def test_invalid_json(self, client): """Test handling of invalid JSON.""" response = client.post( "/v1/chat/completions", - data="invalid json", + content="invalid json", headers={"content-type": "application/json"}, ) # API returns 400 for JSON decode errors (handled manually) assert response.status_code == 400 def test_missing_required_fields(self, client): - """Test handling of missing required fields.""" - request_data = { - "messages": [{"role": "user", "content": "Hi"}] - # Missing required "model" field - } + """Allow missing model and rely on CLI default model selection.""" + request_data = {"messages": [{"role": "user", "content": "Hi"}]} response = client.post("/v1/chat/completions", json=request_data) - assert response.status_code == 422 # Validation error + assert response.status_code in [200, 503] def test_invalid_message_role(self, client): """Test handling of invalid message role.""" @@ -635,10 +692,6 @@ def test_multi_turn_conversation(self, client): assert len(data["choices"]) > 0 -# Test configuration and markers -pytestmark = pytest.mark.asyncio - - if __name__ == "__main__": # Run tests with coverage pytest.main([__file__, "-v", "--tb=short", "--disable-warnings"]) diff --git a/tests/test_logging_config.py b/tests/test_logging_config.py new file mode 100644 index 0000000..6f2acb6 --- /dev/null +++ b/tests/test_logging_config.py @@ -0,0 +1,62 @@ +"""Tests for centralized logging configuration.""" + +import logging +from types import SimpleNamespace + +import pytest +import structlog + +from claude_code_api.core import logging_config + + +def test_minimal_event_filter_allows_warning_and_lifecycle_info(): + processor = logging_config._minimal_event_filter(False, "WARNING") + assert processor is not None + + warning_event = {"event": "warning event"} + assert processor(None, "warning", warning_event) is warning_event + + exception_event = {"event": "exception event"} + assert processor(None, "exception", exception_event) is exception_event + + lifecycle_event = {"event": "Starting Claude Code API Gateway"} + assert processor(None, "info", lifecycle_event) is lifecycle_event + + with pytest.raises(structlog.DropEvent): + processor(None, "info", {"event": "suppressed info"}) + + +def test_configure_logging_falls_back_to_console_when_file_handler_fails(monkeypatch): + original_root = logging.getLogger() + original_handlers = list(original_root.handlers) + original_level = original_root.level + + def raise_oserror(*args, **kwargs): + raise OSError("cannot create log file") + + monkeypatch.setattr(logging_config, "_create_file_handler", raise_oserror) + + settings = SimpleNamespace( + debug=False, + log_level="INFO", + log_format="json", + log_file_path="/not-writable/claude.log", + log_to_file=True, + log_max_bytes=1024, + log_backup_count=1, + log_to_console=False, + log_min_level_when_not_debug="WARNING", + ) + + try: + logging_config.configure_logging(settings) + handlers = logging.getLogger().handlers + assert handlers + assert all(isinstance(handler, logging.StreamHandler) for handler in handlers) + finally: + root_logger = logging.getLogger() + root_logger.handlers.clear() + for handler in original_handlers: + root_logger.addHandler(handler) + root_logger.setLevel(original_level) + structlog.reset_defaults() diff --git a/tests/test_models_unit.py b/tests/test_models_unit.py new file mode 100644 index 0000000..7168d0d --- /dev/null +++ b/tests/test_models_unit.py @@ -0,0 +1,76 @@ +"""Unit tests for Claude model configuration and fallback behavior.""" + +import json + +import pytest + +from claude_code_api.models import claude as claude_models + + +@pytest.fixture(autouse=True) +def clear_models_cache(): + claude_models._load_models_config.cache_clear() + yield + claude_models._load_models_config.cache_clear() + + +def test_opus_46_is_available(): + available_models = {model.id for model in claude_models.get_available_models()} + assert "claude-opus-4-6-20260205" in available_models + + +def test_opus_alias_resolves_to_canonical_model(): + assert ( + claude_models.validate_claude_model("claude-opus-4-6") + == "claude-opus-4-6-20260205" + ) + assert claude_models.validate_claude_model("opus") == "claude-opus-4-6-20260205" + + +def test_opus_45_falls_forward_to_latest_opus_when_missing(tmp_path, monkeypatch): + custom_models_path = tmp_path / "models.json" + custom_models_path.write_text( + json.dumps( + { + "default_model": "claude-sonnet-4-5-20250929", + "models": [ + { + "id": "claude-opus-4-6-20260205", + "name": "Claude Opus 4.6", + "description": "Latest Opus", + "max_tokens": 65536, + "input_cost_per_1k": 0.005, + "output_cost_per_1k": 0.025, + "supports_streaming": True, + "supports_tools": True, + }, + { + "id": "claude-sonnet-4-5-20250929", + "name": "Claude Sonnet 4.5", + "description": "Default Sonnet", + "max_tokens": 65536, + "input_cost_per_1k": 0.003, + "output_cost_per_1k": 0.015, + "supports_streaming": True, + "supports_tools": True, + }, + ], + } + ), + encoding="utf-8", + ) + + monkeypatch.setenv(claude_models.MODELS_CONFIG_ENV, str(custom_models_path)) + claude_models._load_models_config.cache_clear() + + assert ( + claude_models.validate_claude_model("claude-opus-4-5-20251101") + == "claude-opus-4-6-20260205" + ) + + +def test_unknown_model_still_falls_back_to_default(): + assert ( + claude_models.validate_claude_model("totally-unknown-model") + == claude_models.get_default_model() + ) diff --git a/tests/test_session_manager_unit.py b/tests/test_session_manager_unit.py index 08111c1..d5abc8c 100644 --- a/tests/test_session_manager_unit.py +++ b/tests/test_session_manager_unit.py @@ -1,5 +1,6 @@ """Unit tests for session manager behaviors.""" +import json import types from datetime import timedelta @@ -80,3 +81,19 @@ async def test_session_stats(): assert stats["total_messages"] == 3 assert set(stats["models_in_use"]) == {"m1", "m2"} await manager.cleanup_all() + + +@pytest.mark.asyncio +async def test_persist_cli_session_map_writes_expected_payload(tmp_path): + manager = SessionManager() + manager.session_map_path = str(tmp_path / "maps" / "session_map.json") + manager.cli_session_index = {"cli-1": "api-1"} + + manager._persist_cli_session_map() + + with open(manager.session_map_path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + assert payload["cli_to_api"]["cli-1"] == "api-1" + assert list((tmp_path / "maps").glob("session_map_*.tmp")) == [] + + await manager.cleanup_all()