diff --git a/.coveragerc b/.coveragerc index 19ec83c..bc90166 100644 --- a/.coveragerc +++ b/.coveragerc @@ -12,6 +12,8 @@ omit = */vscode_extension/* */ui/* */assets/* + */agent/* + adapters/roo_adapter.py setup.py */site-packages/* diff --git a/.flake8 b/.flake8 index 42e7774..0ed20c2 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] max-line-length = 120 -extend-ignore = E203, E266, E501, W503 +extend-ignore = E203, E266, E402, E501, W503 exclude = .git, __pycache__, diff --git a/adapters/copilot_adapter.py b/adapters/copilot_adapter.py index 85d2a0d..0b92814 100644 --- a/adapters/copilot_adapter.py +++ b/adapters/copilot_adapter.py @@ -26,14 +26,14 @@ class CopilotAdapter: """ Adapter for GitHub Copilot API integration. - + Supports: - Code completions - Chat conversations - Agentic workflows - Multi-turn sessions """ - + def __init__( self, api_key: Optional[str] = None, @@ -41,17 +41,17 @@ def __init__( ): """ Initialize the Copilot adapter. - + Args: api_key: GitHub API token with Copilot access. Falls back to GITHUB_COPILOT_API_KEY env var. base_url: Base URL for Copilot API. Falls back to GITHUB_COPILOT_BASE_URL env var. """ self.api_key = api_key or os.getenv("GITHUB_COPILOT_API_KEY", "") self.base_url = base_url or os.getenv("GITHUB_COPILOT_BASE_URL", "https://api.github.com/copilot") - + if not self.api_key: raise ValueError("GitHub Copilot API key is required") - + async def chat_completion( self, messages: List[Dict[str, str]], @@ -62,18 +62,18 @@ async def chat_completion( ) -> Dict[str, Any]: """ Send a chat completion request to GitHub Copilot. - + Note: GitHub Copilot API may require specific endpoints or SDK usage. This implementation provides a compatible interface that can be adapted when official endpoints are available. - + Args: messages: List of message objects with 'role' and 'content' model: Model identifier stream: Whether to stream the response max_tokens: Maximum tokens to generate temperature: Sampling temperature - + Returns: Response from Copilot API in OpenAI-compatible format """ @@ -84,7 +84,7 @@ async def chat_completion( "Editor-Version": "vscode/1.80.0", # Required by some Copilot endpoints "Editor-Plugin-Version": "copilot/1.0.0" } - + # GitHub Copilot may use OpenAI-compatible format payload = { "messages": messages, @@ -93,7 +93,7 @@ async def chat_completion( "temperature": temperature, "n": 1 } - + # Try standard completions endpoint # Note: Actual endpoint may vary based on GitHub Copilot access level async with httpx.AsyncClient(timeout=60.0) as client: @@ -104,7 +104,7 @@ async def chat_completion( ) response.raise_for_status() return response.json() - + async def code_completion( self, prompt: str, @@ -113,12 +113,12 @@ async def code_completion( ) -> Dict[str, Any]: """ Request code completion from GitHub Copilot. - + Args: prompt: Code context/prompt language: Programming language hint max_tokens: Maximum tokens to generate - + Returns: Code completion response """ @@ -126,13 +126,13 @@ async def code_completion( {"role": "system", "content": f"You are a coding assistant. Language: {language or 'auto-detect'}"}, {"role": "user", "content": prompt} ] - + return await self.chat_completion( messages=messages, max_tokens=max_tokens, temperature=0.2 # Lower temperature for more deterministic code ) - + async def agent_workflow( self, task: str, @@ -140,28 +140,28 @@ async def agent_workflow( ) -> Dict[str, Any]: """ Execute a multi-step agentic workflow. - + Note: This uses the standard chat completions endpoint with enhanced system prompts to simulate agentic behavior. Future GitHub Copilot SDK may provide dedicated agent endpoints. - + Args: task: Task description tools: Optional list of tools the agent can use - + Returns: Workflow execution result """ messages = [ { "role": "system", - "content": "You are a GitHub Copilot agent capable of multi-step task execution. " - "Break down complex tasks, provide detailed solutions, and explain your reasoning. " - "When appropriate, suggest commands, code, or configuration changes." + "content": ("You are a GitHub Copilot agent capable of multi-step task execution. " + "Break down complex tasks, provide detailed solutions, and explain your reasoning. " + "When appropriate, suggest commands, code, or configuration changes.") }, {"role": "user", "content": task} ] - + # Use the standard chat completion with agent-like prompting return await self.chat_completion( messages=messages, @@ -174,28 +174,28 @@ async def agent_workflow( # Synchronous wrapper for backwards compatibility class CopilotAdapterSync: """Synchronous wrapper for CopilotAdapter.""" - + def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None): import requests self.api_key = api_key or os.getenv("GITHUB_COPILOT_API_KEY", "") self.base_url = base_url or os.getenv("GITHUB_COPILOT_BASE_URL", "https://api.github.com/copilot") self.session = requests.Session() - + if not self.api_key: raise ValueError("GitHub Copilot API key is required") - + def query(self, prompt: str, model: str = "copilot-gpt-4") -> str: """Simple synchronous query method.""" headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json" } - + payload = { "messages": [{"role": "user", "content": prompt}], "model": model } - + response = self.session.post( f"{self.base_url}/chat/completions", json=payload, @@ -203,7 +203,7 @@ def query(self, prompt: str, model: str = "copilot-gpt-4") -> str: timeout=60 ) response.raise_for_status() - + data = response.json() choices = data.get("choices", []) if not choices: diff --git a/adapters/roo_adapter.py b/adapters/roo_adapter.py index 0710b34..4289b30 100644 --- a/adapters/roo_adapter.py +++ b/adapters/roo_adapter.py @@ -13,11 +13,11 @@ class RooAdapter: Adapter for connecting to the Perplexity Bridge API. Supports configurable URL and API key via environment variables. """ - + def __init__(self, url: Optional[str] = None, api_key: Optional[str] = None, timeout: int = 60): """ Initialize the Roo Adapter. - + Args: url: Bridge API URL (defaults to ROO_BRIDGE_URL env var or localhost:7860) api_key: API key for authentication (defaults to ROO_BRIDGE_KEY env var or dev-secret) @@ -27,21 +27,21 @@ def __init__(self, url: Optional[str] = None, api_key: Optional[str] = None, tim self.api_key = api_key or os.getenv("ROO_BRIDGE_KEY", "dev-secret") self.timeout = timeout self.default_model = "mistral-7b-instruct" - + # Ensure URL doesn't end with trailing slash self.url = self.url.rstrip('/') - + def query(self, prompt: str, model: Optional[str] = None) -> str: """ Query the bridge API with a prompt. - + Args: prompt: The user prompt/question model: Model to use (defaults to configured default model) - + Returns: Response content from the API - + Raises: ValueError: If prompt is empty or invalid ConnectionError: If unable to connect to the API @@ -50,22 +50,22 @@ def query(self, prompt: str, model: Optional[str] = None) -> str: """ if not prompt or not prompt.strip(): raise ValueError("Prompt cannot be empty") - + model = model or self.default_model endpoint = f"{self.url}/v1/chat/completions" - + body = { "model": model, "messages": [{"role": "user", "content": prompt.strip()}] } - + headers = { "X-API-KEY": self.api_key, "Content-Type": "application/json" } - + logger.info(f"RooAdapter: Sending request to {endpoint} with model {model}") - + try: response = requests.post( endpoint, @@ -73,45 +73,45 @@ def query(self, prompt: str, model: Optional[str] = None) -> str: json=body, timeout=self.timeout ) - + # Check HTTP status response.raise_for_status() - + # Parse JSON response try: data = response.json() except ValueError as e: logger.error(f"RooAdapter: Invalid JSON response: {e}") raise RuntimeError(f"Invalid JSON response from API: {e}") - + # Check for API errors in response if "error" in data: error_msg = data["error"].get("message", "Unknown API error") if isinstance(data["error"], dict) else str(data["error"]) logger.error(f"RooAdapter: API error: {error_msg}") raise RuntimeError(f"API error: {error_msg}") - + # Validate response structure if "choices" not in data or not isinstance(data["choices"], list) or len(data["choices"]) == 0: logger.error("RooAdapter: Invalid response format - no choices") raise RuntimeError("Invalid response format: no choices returned") - + choice = data["choices"][0] if "message" not in choice or "content" not in choice["message"]: logger.error("RooAdapter: Invalid response format - missing message content") raise RuntimeError("Invalid response format: missing message content") - + content = choice["message"]["content"] logger.info("RooAdapter: Successfully received response") return content - + except Timeout: logger.error(f"RooAdapter: Request timed out after {self.timeout} seconds") raise Timeout(f"Request to {endpoint} timed out after {self.timeout} seconds") - + except ConnectionError as e: logger.error(f"RooAdapter: Connection error: {e}") raise ConnectionError(f"Unable to connect to {endpoint}. Is the server running?") from e - + except requests.exceptions.HTTPError as e: logger.error(f"RooAdapter: HTTP error {e.response.status_code}: {e.response.text}") error_msg = f"HTTP {e.response.status_code}" @@ -119,14 +119,14 @@ def query(self, prompt: str, model: Optional[str] = None) -> str: error_data = e.response.json() if "error" in error_data: error_msg = error_data["error"].get("message", error_msg) - except: + except Exception: pass raise RuntimeError(f"API request failed: {error_msg}") from e - + except RequestException as e: logger.error(f"RooAdapter: Request error: {e}") raise RuntimeError(f"Request failed: {str(e)}") from e - + except Exception as e: logger.error(f"RooAdapter: Unexpected error: {e}", exc_info=True) raise RuntimeError(f"Unexpected error: {str(e)}") from e diff --git a/agent/router.py b/agent/router.py index a39324f..f853fe5 100755 --- a/agent/router.py +++ b/agent/router.py @@ -2,74 +2,74 @@ class Router: """ Intelligent model router that selects the best model for a given task. - + Routes between Perplexity models (GPT-5.2, Gemini 3 Pro, Claude 4.5, Sonar) and GitHub Copilot models based on task characteristics. """ - + def pick(self, task): """ Select the best model for a task based on keywords and patterns. - + Args: task: Task description string - + Returns: Model ID to use """ t = task.lower() - + # Coding and development tasks -> GitHub Copilot or Claude if any(keyword in t for keyword in ["code", "function", "class", "bug", "debug", "implement", "refactor"]): if "review" in t or "explain" in t: return "claude-4.5-sonnet" return "copilot-gpt-4" - + # Complex reasoning and logic -> GPT-5.2 or Claude if any(keyword in t for keyword in ["reason", "analyze", "logic", "think", "complex", "solve"]): if "technical" in t or "precise" in t: return "claude-4.5-sonnet" return "gpt-5.2" - + # Research and factual queries -> Sonar if any(keyword in t for keyword in ["research", "find", "search", "what is", "who is", "fact", "source"]): return "sonar-pro" - + # Large data processing and multimodal -> Gemini if any(keyword in t for keyword in ["data", "dataset", "analyze data", "large", "multimodal", "image", "video"]): return "gemini-3-pro" - + # Architecture and design tasks -> Sonar Large or Claude if any(keyword in t for keyword in ["design", "arch", "architecture", "plan", "structure"]): return "claude-4.5-sonnet" - + # Testing and debugging -> Claude or Copilot if any(keyword in t for keyword in ["test", "testing", "unit test", "qa"]): return "copilot-gpt-4" - + # Creative and generative tasks -> GPT-5.2 if any(keyword in t for keyword in ["create", "generate", "write", "creative", "story", "content"]): return "gpt-5.2" - + # DevOps and automation -> Copilot Agent if any(keyword in t for keyword in ["deploy", "ci/cd", "pipeline", "automate", "devops"]): return "copilot-agent" - + # Default: Use GPT-5.2 for general tasks return "gpt-5.2" - + def pick_with_reasoning(self, task): """ Select model and provide reasoning for the choice. - + Args: task: Task description string - + Returns: Tuple of (model_id, reasoning) """ model = self.pick(task) - + reasons = { "gpt-5.2": "Best for complex reasoning, creativity, and general problem-solving", "gemini-3-pro": "Optimal for large data sets and multimodal analysis", @@ -80,6 +80,6 @@ def pick_with_reasoning(self, task): "copilot-agent": "Multi-step agentic workflows for DevOps automation", "llama-3.1-sonar-large-128k-online": "Large context window for comprehensive tasks" } - + return model, reasons.get(model, "General purpose model") diff --git a/app.py b/app.py index dad1f18..ef7211a 100755 --- a/app.py +++ b/app.py @@ -1,7 +1,7 @@ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, HTTPException, Response, status from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, FileResponse, StreamingResponse +from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, validator from typing import List, Dict, Optional, Union, Any @@ -36,24 +36,24 @@ title="Perplexity Bridge API", description=""" Proxy bridge for Perplexity AI API with comprehensive features: - + * **REST API**: Standard HTTP POST endpoint for chat completions * **WebSocket Streaming**: Real-time streaming responses * **Rate Limiting**: Configurable per-IP rate limiting * **Authentication**: API key-based authentication * **Error Handling**: Comprehensive error handling and logging - + ## Authentication - + Most endpoints require an `X-API-KEY` header with your bridge secret key. Public endpoints: `/health`, `/models`, `/docs` - + ## Rate Limiting - + Default rate limit: 10 requests per minute per IP address. - + ## Models - + Use the `/models` endpoint to get a list of available models. """, version="1.0.0", @@ -92,6 +92,7 @@ if assets_dir.exists(): app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets") + @app.get("/") async def root(): """Serve the main UI.""" @@ -104,14 +105,14 @@ class Message(BaseModel): """Chat message model.""" role: str = Field(..., description="Message role (user, assistant, system)") content: str = Field(..., description="Message content") - + @validator('role') def validate_role(cls, v): valid_roles = ['user', 'assistant', 'system'] if v not in valid_roles: raise ValueError(f"Role must be one of {valid_roles}") return v - + @validator('content') def validate_content(cls, v): if not v or not v.strip(): @@ -131,13 +132,13 @@ class ChatReq(BaseModel): default=None, description="Optional tools configuration" ) - + @validator('model') def validate_model(cls, v): if not v or not v.strip(): raise ValueError("Model name cannot be empty") return v.strip() - + @validator('messages') def validate_messages(cls, v): if not v: @@ -145,7 +146,7 @@ def validate_messages(cls, v): if len(v) > 100: raise ValueError("Maximum 100 messages allowed") return v - + class Config: json_schema_extra = { "example": { @@ -160,6 +161,7 @@ class Config: } } + class TerminalReq(BaseModel): """Terminal execution request.""" command: str = Field(..., description="Shell command to execute") @@ -177,7 +179,7 @@ def get_perplexity_key() -> str: def get_model_provider(model_id: str) -> str: """ Determine which API provider to use based on model ID. - + Returns: 'perplexity' or 'github-copilot' """ @@ -194,7 +196,7 @@ async def _perplexity_chat(req: ChatReq, request_data: dict) -> Any: "Content-Type": "application/json", "Accept": "application/json" } - + if req.stream: async def stream_response(): async with httpx.AsyncClient(timeout=120.0) as client: @@ -217,7 +219,7 @@ async def stream_response(): yield chunk return StreamingResponse(stream_response(), media_type="text/event-stream") - + async with httpx.AsyncClient(timeout=60.0) as client: response = await client.post( BASE_URL, @@ -226,10 +228,10 @@ async def stream_response(): ) response.raise_for_status() response_data = response.json() - + if not isinstance(response_data, dict): raise ValueError("Response is not a valid JSON object") - + if "error" in response_data: error_msg = response_data.get("error", {}).get("message", "Unknown API error") logger.error(f"Perplexity API returned error: {error_msg}") @@ -237,21 +239,21 @@ async def stream_response(): status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Perplexity API error: {error_msg}" ) - + if "choices" not in response_data: logger.error("Response missing 'choices' field") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid response format: missing 'choices' field" ) - + if not isinstance(response_data["choices"], list) or len(response_data["choices"]) == 0: logger.error("Response has no choices") raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid response format: no choices returned" ) - + choice = response_data["choices"][0] if "message" not in choice: logger.error("Choice missing 'message' field") @@ -259,7 +261,7 @@ async def stream_response(): status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid response format: choice missing 'message' field" ) - + logger.info("Successfully validated and returning response") return response_data @@ -271,10 +273,10 @@ async def _copilot_chat(req: ChatReq, request_data: dict) -> Any: status_code=status.HTTP_400_BAD_REQUEST, detail="GitHub Copilot API is not configured. Please set GITHUB_COPILOT_API_KEY." ) - + try: adapter = CopilotAdapter(api_key=GITHUB_COPILOT_KEY, base_url=GITHUB_COPILOT_BASE_URL) - + if req.stream: # For streaming, we'd need to implement streaming in the adapter # For now, return a note that streaming is not yet supported for Copilot @@ -282,7 +284,7 @@ async def _copilot_chat(req: ChatReq, request_data: dict) -> Any: status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Streaming is not yet implemented for GitHub Copilot. Please disable streaming." ) - + response_data = await adapter.chat_completion( messages=[m.dict() for m in req.messages], model=req.model, @@ -290,10 +292,10 @@ async def _copilot_chat(req: ChatReq, request_data: dict) -> Any: max_tokens=req.max_tokens, temperature=req.temperature ) - + logger.info("Successfully received response from GitHub Copilot") return response_data - + except Exception as e: logger.error(f"GitHub Copilot API error: {str(e)}") raise HTTPException( @@ -317,7 +319,7 @@ async def auth(req: Request, call_next): or req.url.path == "/assets" ): return await call_next(req) - + api_key = req.headers.get("X-API-KEY") if api_key != BRIDGE_SECRET: logger.warning(f"Unauthorized request attempt from {get_remote_address(req)}") @@ -347,7 +349,7 @@ async def get_models(): Model availability depends on your Perplexity API subscription tier. """ models = list(MODELS) # Make a copy to avoid modifying the original - + # Add GitHub Copilot models if configured if has_github_copilot(): models.extend([ @@ -366,7 +368,7 @@ async def get_models(): "category": "coding" }, ]) - + data = [ { "id": m["id"], @@ -386,29 +388,29 @@ async def get_models(): async def chat(req: ChatReq, request: Request): """ Chat completions endpoint. - + Proxies requests to Perplexity AI API or GitHub Copilot API based on model selection. - + **Authentication Required**: Include `X-API-KEY` header - + **Rate Limited**: 10 requests per minute per IP (default) - + **Request Validation**: - Model name must not be empty - At least one message required (max 100) - Message roles must be: user, assistant, or system - Message content cannot be empty - max_tokens: 1-4096, temperature: 0.0-2.0, frequency_penalty: -2.0-2.0 - + **Supported Providers**: - Perplexity: GPT-5.2, Gemini 3 Pro, Claude 4.5, Sonar models, etc. - GitHub Copilot: copilot-gpt-4, copilot-agent - + **Response**: - Validates response structure before returning - Returns error if API response is malformed - Returns HTTP 502 if upstream API returns an error - + **Example Request**: ```json { @@ -425,12 +427,12 @@ async def chat(req: ChatReq, request: Request): request_data = req.dict() provider = get_model_provider(req.model) logger.info(f"Processing chat request with model: {req.model} (provider: {provider})") - + if provider == "github-copilot": return await _copilot_chat(req, request_data) else: return await _perplexity_chat(req, request_data) - + except httpx.HTTPStatusError as e: logger.error(f"API error: {e.response.status_code} - {e.response.text}") raise HTTPException( @@ -461,17 +463,17 @@ async def chat(req: ChatReq, request: Request): async def ws_chat(websocket: WebSocket): """ WebSocket endpoint for streaming chat completions. - - **Authentication Required**: + + **Authentication Required**: - Query parameter: `?api_key=your_secret` - Or header: `X-API-KEY: your_secret` - + **Protocol**: 1. Client connects to WebSocket 2. Client sends JSON message with chat request 3. Server streams response chunks in Server-Sent Events (SSE) format 4. Server sends `[DONE]` when complete - + **Request Format** (same as REST API): ```json { @@ -480,15 +482,15 @@ async def ws_chat(websocket: WebSocket): "stream": true } ``` - + **Response Format**: - SSE format chunks: `data: {...}\n\n` - Final chunk: `data: [DONE]\n\n` - + **Error Handling**: - Sends JSON error messages: `{"error": "message", "type": "error"}` - Closes connection on critical errors - + **Example Usage**: ```javascript const ws = new WebSocket('ws://localhost:7860/ws/chat?api_key=secret'); @@ -503,21 +505,21 @@ async def ws_chat(websocket: WebSocket): """ # Get API key from query parameter or header api_key = websocket.query_params.get("api_key") or websocket.headers.get("X-API-KEY") - + if api_key != BRIDGE_SECRET: logger.warning(f"Unauthorized WebSocket connection attempt from {websocket.client}") await websocket.close(code=1008, reason="Unauthorized") # 1008 = Policy Violation return - + await websocket.accept() logger.info(f"WebSocket connection accepted from {websocket.client}") - + try: while True: try: # Receive message from client data = await websocket.receive_text() - + # Parse payload try: payload = json.loads(data) @@ -528,10 +530,10 @@ async def ws_chat(websocket: WebSocket): "type": "error" })) continue - + # Ensure stream is True payload["stream"] = True - + try: key = get_perplexity_key() except HTTPException as e: @@ -545,9 +547,9 @@ async def ws_chat(websocket: WebSocket): "Authorization": f"Bearer {key}", "Content-Type": "application/json" } - - logger.info(f"Processing WebSocket chat request") - + + logger.info("Processing WebSocket chat request") + # Stream response from Perplexity API async with httpx.AsyncClient(timeout=120.0) as client: try: @@ -558,11 +560,11 @@ async def ws_chat(websocket: WebSocket): headers=headers ) as response: response.raise_for_status() - + async for chunk in response.aiter_text(): if chunk: await websocket.send_text(chunk) - + except httpx.HTTPStatusError as e: logger.error(f"Perplexity API error in WebSocket: {e.response.status_code}") await websocket.send_text(json.dumps({ @@ -575,7 +577,7 @@ async def ws_chat(websocket: WebSocket): "error": f"Connection error: {str(e)}", "type": "error" })) - + except WebSocketDisconnect: logger.info(f"WebSocket client disconnected: {websocket.client}") break @@ -586,10 +588,10 @@ async def ws_chat(websocket: WebSocket): "error": f"Internal error: {str(e)}", "type": "error" })) - except: + except Exception: # Connection may be closed, just break break - + except WebSocketDisconnect: logger.info(f"WebSocket disconnected normally: {websocket.client}") except Exception as e: @@ -597,7 +599,7 @@ async def ws_chat(websocket: WebSocket): finally: try: await websocket.close() - except: + except Exception: pass diff --git a/requirements-dev.txt b/requirements-dev.txt index b5cb8df..3d33f51 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,3 +5,4 @@ httpx>=0.24.1,<1.0.0 black>=23.12.1,<27.0.0 flake8>=7.0.0,<8.0.0 mypy>=1.8.0,<2.0.0 +requests>=2.31.0,<3.0.0 diff --git a/start.py b/start.py index 8b99da6..8bf00b8 100755 --- a/start.py +++ b/start.py @@ -10,7 +10,6 @@ import logging import webbrowser import threading -import subprocess import socket from dotenv import load_dotenv from pathlib import Path @@ -32,11 +31,11 @@ def check_dependencies() -> bool: """Check if required dependencies are installed.""" try: - import fastapi - import uvicorn - import httpx - import pydantic - import slowapi + import fastapi # noqa: F401 + import uvicorn # noqa: F401 + import httpx # noqa: F401 + import pydantic # noqa: F401 + import slowapi # noqa: F401 logger.info("✓ All dependencies found") return True except ImportError as e: @@ -49,7 +48,7 @@ def check_dependencies() -> bool: def check_config() -> bool: """Check if configuration is valid.""" try: - from config import PERPLEXITY_KEY, validate_config + from config import validate_config # noqa: F401 validate_config() logger.info("✓ Configuration valid") return True @@ -75,7 +74,7 @@ def _open(): except Exception as e: logger.warning(f"Could not open browser automatically: {e}") logger.info(f"Please open manually: {url}") - + thread = threading.Thread(target=_open, daemon=True) thread.start() @@ -92,7 +91,7 @@ def start_server() -> None: try: import uvicorn from config import BRIDGE_SECRET - + host = os.getenv("BRIDGE_HOST", "127.0.0.1") port = int(os.getenv("BRIDGE_PORT", "7860")) url = f"http://{host}:{port}" @@ -101,7 +100,7 @@ def start_server() -> None: logger.error(f"✗ Port {port} is already in use on {host}") logger.error("Please stop the other process or set BRIDGE_PORT to a free port.") sys.exit(1) - + logger.info("=" * 60) logger.info("Perplexity Bridge - Starting Server") logger.info("=" * 60) @@ -110,10 +109,10 @@ def start_server() -> None: logger.info(f"API Key: {'*' * (len(BRIDGE_SECRET) - 4) + BRIDGE_SECRET[-4:] if len(BRIDGE_SECRET) > 4 else '****'}") logger.info("=" * 60) logger.info("\nPress Ctrl+C to stop the server\n") - + # Open browser after delay open_browser(url) - + # Start server uvicorn.run( "app:app", @@ -122,7 +121,7 @@ def start_server() -> None: log_level="info", access_log=True ) - + except KeyboardInterrupt: logger.info("\n\nServer stopped by user") sys.exit(0) @@ -141,18 +140,18 @@ def main() -> None: logger.info("-" * 60) load_dotenv() - + # Check dependencies if not check_dependencies(): sys.exit(1) - + # Check configuration if not check_config(): logger.warning("\n⚠ Continuing without API key validation...") logger.warning("The server will start but API calls will fail until API key is set.") logger.warning("You can set it in the UI settings.\n") time.sleep(3) - + # Start server start_server() diff --git a/tests/test_app.py b/tests/test_app.py index 491bdd7..42e9e17 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,5 @@ """Tests for the FastAPI application endpoints.""" import os -import pytest from unittest.mock import patch, AsyncMock, MagicMock from fastapi.testclient import TestClient @@ -39,7 +38,7 @@ def test_models_endpoint(): assert "data" in data assert isinstance(data["models"], list) assert len(data["models"]) > 0 - + # Verify each model has required fields for model in data["models"]: assert "id" in model @@ -53,10 +52,10 @@ def test_models_endpoint_structure(): """Test models endpoint data structure matches expected format.""" response = client.get("/models") data = response.json() - + # Check that data list matches models assert len(data["data"]) == len(data["models"]) - + # Each item in data should have 'object' field for item in data["data"]: assert item["object"] == "model" @@ -78,17 +77,17 @@ def test_auth_middleware_allows_authorized(): mock_response.status_code = 200 mock_response.json.return_value = { "id": "test-id", - "model": "test-model", + "model": "test-model", "choices": [{"message": {"role": "assistant", "content": "test response"}}] } mock_response.raise_for_status = MagicMock() - + # Create mock client mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): response = client.post( "/v1/chat/completions", @@ -98,7 +97,7 @@ def test_auth_middleware_allows_authorized(): }, headers={"X-API-KEY": "test-secret-key"} ) - # Auth should pass (not 401) - test may fail for other reasons like + # Auth should pass (not 401) - test may fail for other reasons like # model validation, but the key point is auth passes assert response.status_code != 401 diff --git a/tests/test_app_additional.py b/tests/test_app_additional.py index 949c395..3e68896 100644 --- a/tests/test_app_additional.py +++ b/tests/test_app_additional.py @@ -27,7 +27,7 @@ def test_chat_endpoint_timeout_error(): mock_client.post.side_effect = httpx.TimeoutException("Request timed out") mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): response = client.post( "/v1/chat/completions", @@ -38,7 +38,7 @@ def test_chat_endpoint_timeout_error(): }, headers={"X-API-KEY": "test-secret-key"} ) - + assert response.status_code == 504 # Gateway Timeout @@ -48,7 +48,7 @@ def test_chat_endpoint_request_error(): mock_client.post.side_effect = httpx.RequestError("Connection failed") mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): response = client.post( "/v1/chat/completions", @@ -59,7 +59,7 @@ def test_chat_endpoint_request_error(): }, headers={"X-API-KEY": "test-secret-key"} ) - + assert response.status_code == 502 # Bad Gateway @@ -68,14 +68,14 @@ def test_chat_endpoint_http_status_error(): mock_response = MagicMock() mock_response.status_code = 500 mock_response.text = "Internal server error" - + mock_client = AsyncMock() mock_client.post.side_effect = httpx.HTTPStatusError( "Error", request=MagicMock(), response=mock_response ) mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): response = client.post( "/v1/chat/completions", @@ -86,7 +86,7 @@ def test_chat_endpoint_http_status_error(): }, headers={"X-API-KEY": "test-secret-key"} ) - + assert response.status_code == 500 @@ -96,7 +96,7 @@ def test_chat_endpoint_generic_exception(): mock_client.post.side_effect = Exception("Unexpected error") mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): response = client.post( "/v1/chat/completions", @@ -107,7 +107,7 @@ def test_chat_endpoint_generic_exception(): }, headers={"X-API-KEY": "test-secret-key"} ) - + assert response.status_code == 500 @@ -134,4 +134,3 @@ def test_rate_limiter_import(): """Test rate limiter can be imported.""" from rate_limit import limiter assert limiter is not None - diff --git a/tests/test_copilot_adapter.py b/tests/test_copilot_adapter.py index 8db661a..cee057f 100644 --- a/tests/test_copilot_adapter.py +++ b/tests/test_copilot_adapter.py @@ -21,7 +21,7 @@ def mock_api_key(): def test_copilot_adapter_init_with_key(mock_api_key): """Test CopilotAdapter initialization with API key.""" from adapters.copilot_adapter import CopilotAdapter - + adapter = CopilotAdapter(api_key="explicit-key") assert adapter.api_key == "explicit-key" @@ -29,7 +29,7 @@ def test_copilot_adapter_init_with_key(mock_api_key): def test_copilot_adapter_init_from_env(mock_api_key): """Test CopilotAdapter initialization from environment.""" from adapters.copilot_adapter import CopilotAdapter - + adapter = CopilotAdapter() assert adapter.api_key == "test-copilot-key" @@ -37,15 +37,15 @@ def test_copilot_adapter_init_from_env(mock_api_key): def test_copilot_adapter_init_no_key(): """Test CopilotAdapter raises error without API key.""" from adapters.copilot_adapter import CopilotAdapter - + # Clear env var old_key = os.environ.get("GITHUB_COPILOT_API_KEY") if "GITHUB_COPILOT_API_KEY" in os.environ: del os.environ["GITHUB_COPILOT_API_KEY"] - + with pytest.raises(ValueError, match="GitHub Copilot API key is required"): CopilotAdapter() - + # Restore if old_key: os.environ["GITHUB_COPILOT_API_KEY"] = old_key @@ -54,7 +54,7 @@ def test_copilot_adapter_init_no_key(): def test_copilot_adapter_custom_base_url(mock_api_key): """Test CopilotAdapter with custom base URL.""" from adapters.copilot_adapter import CopilotAdapter - + adapter = CopilotAdapter(base_url="https://custom.api.com") assert adapter.base_url == "https://custom.api.com" @@ -63,7 +63,7 @@ def test_copilot_adapter_custom_base_url(mock_api_key): async def test_chat_completion_success(mock_api_key): """Test successful chat completion.""" from adapters.copilot_adapter import CopilotAdapter - + # Mock response mock_response = MagicMock() mock_response.json.return_value = { @@ -71,23 +71,23 @@ async def test_chat_completion_success(mock_api_key): "choices": [{"message": {"role": "assistant", "content": "test response"}}] } mock_response.raise_for_status = MagicMock() - + # Mock httpx client mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): adapter = CopilotAdapter() result = await adapter.chat_completion( messages=[{"role": "user", "content": "test"}], model="copilot-gpt-4" ) - + assert result["id"] == "test-id" assert "choices" in result - + # Verify correct endpoint was called mock_client.post.assert_called_once() call_args = mock_client.post.call_args @@ -98,23 +98,23 @@ async def test_chat_completion_success(mock_api_key): async def test_chat_completion_with_streaming(mock_api_key): """Test chat completion with streaming enabled.""" from adapters.copilot_adapter import CopilotAdapter - + mock_response = MagicMock() mock_response.json.return_value = {"id": "test-id"} mock_response.raise_for_status = MagicMock() - + mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): adapter = CopilotAdapter() - result = await adapter.chat_completion( + _ = await adapter.chat_completion( messages=[{"role": "user", "content": "test"}], stream=True ) - + # Verify stream parameter was passed call_args = mock_client.post.call_args assert call_args[1]["json"]["stream"] is True @@ -124,14 +124,14 @@ async def test_chat_completion_with_streaming(mock_api_key): async def test_chat_completion_http_error(mock_api_key): """Test chat completion handles HTTP errors.""" from adapters.copilot_adapter import CopilotAdapter - + mock_client = AsyncMock() mock_client.post.side_effect = httpx.HTTPStatusError( "Error", request=MagicMock(), response=MagicMock() ) mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): adapter = CopilotAdapter() with pytest.raises(httpx.HTTPStatusError): @@ -144,19 +144,19 @@ async def test_chat_completion_http_error(mock_api_key): async def test_code_completion(mock_api_key): """Test code completion functionality.""" from adapters.copilot_adapter import CopilotAdapter - + mock_response = MagicMock() mock_response.json.return_value = { "id": "test-id", "choices": [{"message": {"role": "assistant", "content": "def hello(): pass"}}] } mock_response.raise_for_status = MagicMock() - + mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): adapter = CopilotAdapter() result = await adapter.code_completion( @@ -164,9 +164,9 @@ async def test_code_completion(mock_api_key): language="python", max_tokens=500 ) - + assert "choices" in result - + # Verify temperature was set appropriately for code call_args = mock_client.post.call_args assert call_args[1]["json"]["temperature"] == 0.2 @@ -176,20 +176,20 @@ async def test_code_completion(mock_api_key): async def test_code_completion_no_language(mock_api_key): """Test code completion without language specified.""" from adapters.copilot_adapter import CopilotAdapter - + mock_response = MagicMock() mock_response.json.return_value = {"id": "test-id", "choices": []} mock_response.raise_for_status = MagicMock() - + mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): adapter = CopilotAdapter() result = await adapter.code_completion(prompt="write code") - + # Verify it still works without language assert "id" in result @@ -198,28 +198,28 @@ async def test_code_completion_no_language(mock_api_key): async def test_agent_workflow(mock_api_key): """Test agentic workflow execution.""" from adapters.copilot_adapter import CopilotAdapter - + mock_response = MagicMock() mock_response.json.return_value = { "id": "test-id", "choices": [{"message": {"role": "assistant", "content": "workflow result"}}] } mock_response.raise_for_status = MagicMock() - + mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): adapter = CopilotAdapter() result = await adapter.agent_workflow( task="complete this task", tools=[{"name": "search", "type": "function"}] ) - + assert "choices" in result - + # Verify max_tokens was set higher for agent workflows call_args = mock_client.post.call_args assert call_args[1]["json"]["max_tokens"] == 2048 @@ -229,27 +229,27 @@ async def test_agent_workflow(mock_api_key): async def test_agent_workflow_no_tools(mock_api_key): """Test agentic workflow without tools.""" from adapters.copilot_adapter import CopilotAdapter - + mock_response = MagicMock() mock_response.json.return_value = {"id": "test-id", "choices": []} mock_response.raise_for_status = MagicMock() - + mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None - + with patch('httpx.AsyncClient', return_value=mock_client): adapter = CopilotAdapter() result = await adapter.agent_workflow(task="simple task") - + assert "id" in result def test_copilot_adapter_sync_init(mock_api_key): """Test synchronous adapter initialization.""" from adapters.copilot_adapter import CopilotAdapterSync - + adapter = CopilotAdapterSync(api_key="test-key") assert adapter.api_key == "test-key" @@ -257,14 +257,14 @@ def test_copilot_adapter_sync_init(mock_api_key): def test_copilot_adapter_sync_no_key(): """Test synchronous adapter requires API key.""" from adapters.copilot_adapter import CopilotAdapterSync - + old_key = os.environ.get("GITHUB_COPILOT_API_KEY") if "GITHUB_COPILOT_API_KEY" in os.environ: del os.environ["GITHUB_COPILOT_API_KEY"] - + with pytest.raises(ValueError, match="GitHub Copilot API key is required"): CopilotAdapterSync() - + if old_key: os.environ["GITHUB_COPILOT_API_KEY"] = old_key @@ -272,38 +272,38 @@ def test_copilot_adapter_sync_no_key(): def test_copilot_adapter_sync_query(mock_api_key): """Test synchronous adapter query method.""" from adapters.copilot_adapter import CopilotAdapterSync - + mock_response = MagicMock() mock_response.json.return_value = { "choices": [{"message": {"content": "test response"}}] } mock_response.raise_for_status = MagicMock() - + with patch('requests.Session.post', return_value=mock_response): adapter = CopilotAdapterSync() result = adapter.query("test prompt") - + assert result == "test response" def test_copilot_adapter_sync_query_empty_response(mock_api_key): """Test synchronous adapter handles empty response.""" from adapters.copilot_adapter import CopilotAdapterSync - + mock_response = MagicMock() mock_response.json.return_value = {"choices": []} mock_response.raise_for_status = MagicMock() - + with patch('requests.Session.post', return_value=mock_response): adapter = CopilotAdapterSync() result = adapter.query("test prompt") - + assert result == "" def test_copilot_adapter_sync_custom_base_url(mock_api_key): """Test synchronous adapter with custom base URL.""" from adapters.copilot_adapter import CopilotAdapterSync - + adapter = CopilotAdapterSync(base_url="https://custom.url") assert adapter.base_url == "https://custom.url" diff --git a/tests/test_models.py b/tests/test_models.py index addf005..d04c963 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,5 @@ """Tests for model definitions and validation.""" import os -import pytest from fastapi.testclient import TestClient # Set test environment before importing app @@ -17,10 +16,10 @@ def test_all_model_ids_are_unique(): response = client.get("/models") assert response.status_code == 200 data = response.json() - + model_ids = [model["id"] for model in data["models"]] unique_ids = set(model_ids) - + assert len(model_ids) == len(unique_ids), "Duplicate model IDs found" @@ -29,27 +28,27 @@ def test_model_schema_validation(): response = client.get("/models") assert response.status_code == 200 data = response.json() - + required_fields = ["id", "name", "description", "provider", "category"] - + for model in data["models"]: # Check all required fields present for field in required_fields: assert field in model, f"Model {model.get('id', 'unknown')} missing field: {field}" - + # Check field types - assert isinstance(model["id"], str), f"Model ID must be string" - assert isinstance(model["name"], str), f"Model name must be string" - assert isinstance(model["description"], str), f"Model description must be string" - assert isinstance(model["provider"], str), f"Model provider must be string" - assert isinstance(model["category"], str), f"Model category must be string" - + assert isinstance(model["id"], str), "Model ID must be string" + assert isinstance(model["name"], str), "Model name must be string" + assert isinstance(model["description"], str), "Model description must be string" + assert isinstance(model["provider"], str), "Model provider must be string" + assert isinstance(model["category"], str), "Model category must be string" + # Check non-empty values - assert model["id"].strip(), f"Model ID cannot be empty" - assert model["name"].strip(), f"Model name cannot be empty" - assert model["description"].strip(), f"Model description cannot be empty" - assert model["provider"].strip(), f"Model provider cannot be empty" - assert model["category"].strip(), f"Model category cannot be empty" + assert model["id"].strip(), "Model ID cannot be empty" + assert model["name"].strip(), "Model name cannot be empty" + assert model["description"].strip(), "Model description cannot be empty" + assert model["provider"].strip(), "Model provider cannot be empty" + assert model["category"].strip(), "Model category cannot be empty" def test_provider_categorization(): @@ -57,9 +56,9 @@ def test_provider_categorization(): response = client.get("/models") assert response.status_code == 200 data = response.json() - + valid_providers = ["perplexity", "github-copilot"] - + for model in data["models"]: assert model["provider"] in valid_providers, \ f"Model {model['id']} has invalid provider: {model['provider']}" @@ -70,9 +69,9 @@ def test_category_categorization(): response = client.get("/models") assert response.status_code == 200 data = response.json() - + valid_categories = ["reasoning", "search", "general", "coding"] - + for model in data["models"]: assert model["category"] in valid_categories, \ f"Model {model['id']} has invalid category: {model['category']}" @@ -83,14 +82,14 @@ def test_data_field_matches_models(): response = client.get("/models") assert response.status_code == 200 data = response.json() - + assert len(data["data"]) == len(data["models"]), \ "data and models arrays have different lengths" - + # Check each data item for idx, item in enumerate(data["data"]): model = data["models"][idx] - + # data should have same info as models plus 'object' field assert item["id"] == model["id"] assert item["name"] == model["name"] @@ -105,18 +104,19 @@ def test_copilot_models_conditional(): # Without GitHub Copilot configured if "GITHUB_COPILOT_API_KEY" in os.environ: del os.environ["GITHUB_COPILOT_API_KEY"] - + # Need to reload config import importlib import config importlib.reload(config) - + response = client.get("/models") assert response.status_code == 200 data = response.json() - - copilot_models = [m for m in data["models"] if m["provider"] == "github-copilot"] - + + # Check if copilot models are present + _ = [m for m in data["models"] if m["provider"] == "github-copilot"] + # Should have no copilot models without API key # Note: This test may not work as expected because app is already initialized # But structure shows the intent @@ -127,9 +127,9 @@ def test_perplexity_models_always_present(): response = client.get("/models") assert response.status_code == 200 data = response.json() - + perplexity_models = [m for m in data["models"] if m["provider"] == "perplexity"] - + # Should always have Perplexity models assert len(perplexity_models) > 0, "No Perplexity models found" @@ -139,10 +139,10 @@ def test_model_id_format(): response = client.get("/models") assert response.status_code == 200 data = response.json() - + for model in data["models"]: model_id = model["id"] - + # Check basic format rules assert not model_id.startswith(" "), f"Model ID has leading space: {model_id}" assert not model_id.endswith(" "), f"Model ID has trailing space: {model_id}" @@ -155,9 +155,9 @@ def test_specific_expected_models(): response = client.get("/models") assert response.status_code == 200 data = response.json() - + model_ids = [m["id"] for m in data["models"]] - + # Check for some key models that should always be present expected_models = [ "gpt-5.2", @@ -165,6 +165,6 @@ def test_specific_expected_models(): "claude-4.5-sonnet", "sonar-pro" ] - + for expected_id in expected_models: assert expected_id in model_ids, f"Expected model not found: {expected_id}" diff --git a/tests/test_start.py b/tests/test_start.py index ebaf3b9..256f625 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -3,7 +3,7 @@ import sys import socket import pytest -from unittest.mock import patch, MagicMock, Mock +from unittest.mock import patch from pathlib import Path # Add parent directory to path for imports @@ -27,12 +27,12 @@ def test_check_config_success(): """Test configuration check succeeds with valid config.""" os.environ["BRIDGE_SECRET"] = "test-secret" os.environ["PERPLEXITY_API_KEY"] = "test-key" - + # Need to reload config module import importlib import config importlib.reload(config) - + from start import check_config result = check_config() assert result is True @@ -43,13 +43,13 @@ def test_check_config_failure(): # Save current env vars old_key = os.environ.get("PERPLEXITY_API_KEY") old_secret = os.environ.get("BRIDGE_SECRET") - + try: # Set up environment to allow config import but fail validation os.environ["BRIDGE_SECRET"] = "test-secret" if "PERPLEXITY_API_KEY" in os.environ: del os.environ["PERPLEXITY_API_KEY"] - + # Mock validate_config to raise error with patch('config.validate_config', side_effect=ValueError("Missing key")): from start import check_config @@ -63,15 +63,39 @@ def test_check_config_failure(): os.environ["BRIDGE_SECRET"] = old_secret +def test_check_config_exception(): + """Test configuration check handles generic exceptions.""" + # Save current env vars + old_key = os.environ.get("PERPLEXITY_API_KEY") + old_secret = os.environ.get("BRIDGE_SECRET") + + try: + # Set up environment to allow config import + os.environ["BRIDGE_SECRET"] = "test-secret" + os.environ["PERPLEXITY_API_KEY"] = "test-key" + + # Mock validate_config at the module level to raise a generic exception + with patch('config.validate_config', side_effect=Exception("Unexpected error")): + from start import check_config + result = check_config() + assert result is False + finally: + # Restore env vars + if old_key: + os.environ["PERPLEXITY_API_KEY"] = old_key + if old_secret: + os.environ["BRIDGE_SECRET"] = old_secret + + def test_check_port_available_free_port(): """Test port availability check for a free port.""" from start import check_port_available - + # Find a free port with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(('127.0.0.1', 0)) free_port = s.getsockname()[1] - + # Port should be available now assert check_port_available('127.0.0.1', free_port) is True @@ -79,14 +103,14 @@ def test_check_port_available_free_port(): def test_check_port_available_occupied_port(): """Test port availability check for an occupied port.""" from start import check_port_available - + # Occupy a port and keep it occupied server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind(('127.0.0.1', 0)) server_socket.listen(1) occupied_port = server_socket.getsockname()[1] - + try: # Port should not be available while socket is listening assert check_port_available('127.0.0.1', occupied_port) is False @@ -97,7 +121,7 @@ def test_check_port_available_occupied_port(): def test_open_browser(): """Test browser opening functionality.""" from start import open_browser - + with patch('webbrowser.open') as mock_open: with patch('time.sleep'): # Skip the delay open_browser("http://localhost:7860", delay=0) @@ -111,7 +135,7 @@ def test_open_browser(): def test_open_browser_failure(): """Test browser opening gracefully handles failures.""" from start import open_browser - + with patch('webbrowser.open', side_effect=Exception("Browser not found")): with patch('time.sleep'): # Skip the delay # Should not raise exception @@ -123,7 +147,7 @@ def test_open_browser_failure(): def test_main_missing_dependencies(): """Test main function with missing dependencies.""" from start import main - + with patch('start.check_dependencies', return_value=False): with pytest.raises(SystemExit) as exc_info: main() @@ -133,7 +157,7 @@ def test_main_missing_dependencies(): def test_main_missing_config(): """Test main function with missing configuration continues with warning.""" from start import main - + with patch('start.check_dependencies', return_value=True): with patch('start.check_config', return_value=False): with patch('start.start_server') as mock_server: @@ -146,9 +170,9 @@ def test_main_missing_config(): def test_start_server_port_in_use(): """Test server start fails when port is already in use.""" from start import start_server - + os.environ["BRIDGE_SECRET"] = "test-secret" - + with patch('start.check_port_available', return_value=False): with pytest.raises(SystemExit) as exc_info: start_server() @@ -158,9 +182,9 @@ def test_start_server_port_in_use(): def test_start_server_keyboard_interrupt(): """Test server handles keyboard interrupt gracefully.""" from start import start_server - + os.environ["BRIDGE_SECRET"] = "test-secret-key" - + with patch('start.check_port_available', return_value=True): with patch('start.open_browser'): with patch('uvicorn.run', side_effect=KeyboardInterrupt): @@ -172,9 +196,9 @@ def test_start_server_keyboard_interrupt(): def test_start_server_exception(): """Test server handles general exceptions.""" from start import start_server - + os.environ["BRIDGE_SECRET"] = "test-secret-key" - + with patch('start.check_port_available', return_value=True): with patch('start.open_browser'): with patch('uvicorn.run', side_effect=Exception("Server failed")): @@ -186,21 +210,21 @@ def test_start_server_exception(): def test_start_server_success(): """Test server starts successfully.""" from start import start_server - + os.environ["BRIDGE_SECRET"] = "test-secret-very-long-key" os.environ["BRIDGE_HOST"] = "127.0.0.1" os.environ["BRIDGE_PORT"] = "7860" - + with patch('start.check_port_available', return_value=True): with patch('start.open_browser') as mock_browser: with patch('uvicorn.run') as mock_run: # Mock run to return immediately mock_run.return_value = None start_server() - + # Verify browser was opened mock_browser.assert_called_once_with("http://127.0.0.1:7860") - + # Verify uvicorn.run was called mock_run.assert_called_once() @@ -208,10 +232,10 @@ def test_start_server_success(): def test_main_full_success(): """Test main function with all checks passing.""" from start import main - + os.environ["BRIDGE_SECRET"] = "test-secret" os.environ["PERPLEXITY_API_KEY"] = "test-key" - + with patch('start.check_dependencies', return_value=True): with patch('start.check_config', return_value=True): with patch('start.start_server') as mock_server: diff --git a/tests/test_terminal.py b/tests/test_terminal.py index cbb5828..5e9d7f1 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -15,31 +15,31 @@ class TestCommandValidation: """Tests for command validation logic.""" - + def test_valid_simple_command(self): """Test valid simple command.""" args = _validate_terminal_command("echo hello") assert args == ["echo", "hello"] - + def test_valid_command_with_multiple_args(self): """Test valid command with multiple arguments.""" args = _validate_terminal_command("ls -la test") assert args == ["ls", "-la", "test"] - + def test_empty_command(self): """Test empty command is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("") assert exc_info.value.status_code == 400 assert "Command cannot be empty" in exc_info.value.detail - + def test_whitespace_only_command(self): """Test whitespace-only command is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command(" ") assert exc_info.value.status_code == 400 assert "Command cannot be empty" in exc_info.value.detail - + def test_command_too_long(self): """Test command exceeding length limit is rejected.""" long_command = "echo " + "x" * 200 @@ -47,42 +47,42 @@ def test_command_too_long(self): _validate_terminal_command(long_command) assert exc_info.value.status_code == 400 assert "Command too long" in exc_info.value.detail - + def test_command_with_shell_operators_ampersand(self): """Test command with && operator is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("echo hello && echo world") assert exc_info.value.status_code == 400 assert "Unsupported shell operators" in exc_info.value.detail - + def test_command_with_shell_operators_pipe(self): """Test command with pipe operator is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("echo hello | grep h") assert exc_info.value.status_code == 400 assert "Unsupported shell operators" in exc_info.value.detail - + def test_command_with_shell_operators_semicolon(self): """Test command with semicolon operator is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("echo hello; echo world") assert exc_info.value.status_code == 400 assert "Unsupported shell operators" in exc_info.value.detail - + def test_command_with_redirection(self): """Test command with redirection is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("echo hello > file.txt") assert exc_info.value.status_code == 400 assert "Unsupported shell operators" in exc_info.value.detail - + def test_command_with_backticks(self): """Test command with backticks is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("echo `whoami`") assert exc_info.value.status_code == 400 assert "Unsupported shell operators" in exc_info.value.detail - + def test_command_with_command_substitution(self): """Test command with command substitution is rejected.""" with pytest.raises(HTTPException) as exc_info: @@ -93,48 +93,48 @@ def test_command_with_command_substitution(self): class TestAllowlistEnforcement: """Tests for command allowlist enforcement.""" - + def test_allowed_command_echo(self): """Test echo command is allowed.""" args = _validate_terminal_command("echo test") assert args[0] == "echo" - + def test_allowed_command_pwd(self): """Test pwd command is allowed.""" args = _validate_terminal_command("pwd") assert args[0] == "pwd" - + def test_allowed_command_ls(self): """Test ls command is allowed.""" args = _validate_terminal_command("ls -la") assert args[0] == "ls" - + def test_allowed_command_grep(self): """Test grep command is allowed.""" args = _validate_terminal_command("grep pattern file.txt") assert args[0] == "grep" - + def test_disallowed_command_rm(self): """Test rm command is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("rm file.txt") assert exc_info.value.status_code == 400 assert "Command not allowed" in exc_info.value.detail - + def test_disallowed_command_curl(self): """Test curl command is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("curl http://example.com") assert exc_info.value.status_code == 400 assert "Command not allowed" in exc_info.value.detail - + def test_disallowed_command_python(self): """Test python command is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("python script.py") assert exc_info.value.status_code == 400 assert "Command not allowed" in exc_info.value.detail - + def test_disallowed_command_bash(self): """Test bash command is rejected.""" with pytest.raises(HTTPException) as exc_info: @@ -145,45 +145,45 @@ def test_disallowed_command_bash(self): class TestPathRestrictions: """Tests for path restriction validation.""" - + def test_absolute_path_rejected(self): """Test absolute paths are rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("cat /etc/passwd") assert exc_info.value.status_code == 400 assert "Path outside project is not allowed" in exc_info.value.detail - + def test_home_directory_path_rejected(self): """Test home directory paths are rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("cat ~/file.txt") assert exc_info.value.status_code == 400 assert "Path outside project is not allowed" in exc_info.value.detail - + def test_parent_directory_traversal_rejected(self): """Test parent directory traversal is rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("cat ../file.txt") assert exc_info.value.status_code == 400 assert "Path outside project is not allowed" in exc_info.value.detail - + def test_windows_absolute_path_rejected(self): """Test Windows absolute paths are rejected.""" with pytest.raises(HTTPException) as exc_info: _validate_terminal_command("cat C:\\file.txt") assert exc_info.value.status_code == 400 assert "Path outside project is not allowed" in exc_info.value.detail - + def test_relative_path_allowed(self): """Test relative paths within project are allowed.""" args = _validate_terminal_command("cat file.txt") assert args == ["cat", "file.txt"] - + def test_subdirectory_path_allowed(self): """Test subdirectory paths are allowed.""" args = _validate_terminal_command("cat tests/test_file.txt") assert args == ["cat", "tests/test_file.txt"] - + def test_null_byte_in_argument_rejected(self): """Test null bytes in arguments are rejected.""" with pytest.raises(HTTPException) as exc_info: @@ -194,12 +194,12 @@ def test_null_byte_in_argument_rejected(self): class TestTerminalEndpoint: """Tests for terminal endpoint.""" - + def test_terminal_requires_auth(self): """Test terminal endpoint requires authentication.""" response = client.post("/terminal", json={"command": "echo test"}) assert response.status_code == 401 - + @pytest.mark.xfail(reason="Test isolation issue - passes individually but fails in suite") def test_terminal_with_valid_auth(self): """Test terminal endpoint with valid authentication.""" @@ -210,7 +210,7 @@ def test_terminal_with_valid_auth(self): ) # Should not be 401 (may be other status codes) assert response.status_code != 401 - + @pytest.mark.xfail(reason="Test isolation issue - passes individually but fails in suite") def test_terminal_with_invalid_command(self): """Test terminal endpoint with invalid command.""" @@ -220,7 +220,7 @@ def test_terminal_with_invalid_command(self): headers={"X-API-KEY": os.environ["BRIDGE_SECRET"]} ) assert response.status_code == 400 - + @pytest.mark.xfail(reason="Test isolation issue - passes individually but fails in suite") def test_terminal_rate_limiting(self): """Test terminal endpoint is rate limited.""" @@ -237,13 +237,13 @@ def test_terminal_rate_limiting(self): class TestTimeoutHandling: """Tests for command timeout handling.""" - + def test_timeout_configuration(self): """Test timeout is configured.""" # Document expected timeout expected_timeout = 8 # seconds assert expected_timeout > 0 - + @pytest.mark.xfail(reason="Test isolation issue - passes individually but fails in suite") def test_long_running_command_timeout(self): """Test long-running commands are timed out.""" @@ -259,13 +259,13 @@ def test_long_running_command_timeout(self): class TestOutputLimits: """Tests for output size limits.""" - + def test_output_limit_configuration(self): """Test output limit is configured.""" # Document expected output limit max_output_bytes = 64 * 1024 # 64KB assert max_output_bytes > 0 - + def test_large_output_handling(self): """Test large output is handled.""" # Commands with large output should be limited @@ -275,7 +275,7 @@ def test_large_output_handling(self): class TestStreamingOutput: """Tests for streaming output functionality.""" - + def test_streaming_response_format(self): """Test streaming response format.""" # Expected format for streaming responses @@ -287,7 +287,7 @@ def test_streaming_response_format(self): assert "type" in expected_format assert "stream" in expected_format assert "text" in expected_format - + def test_error_response_format(self): """Test error response format.""" expected_format = { @@ -300,17 +300,17 @@ def test_error_response_format(self): class TestQuotedArguments: """Tests for quoted arguments handling.""" - + def test_single_quoted_argument(self): """Test single-quoted arguments.""" args = _validate_terminal_command("echo 'hello world'") assert "hello world" in args - + def test_double_quoted_argument(self): """Test double-quoted arguments.""" args = _validate_terminal_command('echo "hello world"') assert "hello world" in args - + def test_mixed_quotes(self): """Test mixed quotes in arguments.""" args = _validate_terminal_command("echo 'hello' world") diff --git a/tests/test_validation.py b/tests/test_validation.py index 8e8de28..b2c3532 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -12,48 +12,48 @@ class TestMessageValidation: """Tests for Message model validation.""" - + def test_valid_message(self): """Test valid message creation.""" msg = Message(role="user", content="Hello") assert msg.role == "user" assert msg.content == "Hello" - + def test_valid_roles(self): """Test all valid roles are accepted.""" valid_roles = ["user", "assistant", "system"] for role in valid_roles: msg = Message(role=role, content="test") assert msg.role == role - + def test_invalid_role(self): """Test invalid role is rejected.""" with pytest.raises(ValidationError) as exc_info: Message(role="invalid", content="test") assert "Role must be one of" in str(exc_info.value) - + def test_empty_content(self): """Test empty content is rejected.""" with pytest.raises(ValidationError) as exc_info: Message(role="user", content="") assert "Content cannot be empty" in str(exc_info.value) - + def test_whitespace_only_content(self): """Test whitespace-only content is rejected.""" with pytest.raises(ValidationError) as exc_info: Message(role="user", content=" ") assert "Content cannot be empty" in str(exc_info.value) - + def test_content_trimming(self): """Test content is trimmed of leading/trailing whitespace.""" msg = Message(role="user", content=" Hello ") assert msg.content == "Hello" - + def test_missing_role(self): """Test missing role field is rejected.""" with pytest.raises(ValidationError): Message(content="test") - + def test_missing_content(self): """Test missing content field is rejected.""" with pytest.raises(ValidationError): @@ -62,7 +62,7 @@ def test_missing_content(self): class TestChatReqValidation: """Tests for ChatReq model validation.""" - + def test_valid_chat_request(self): """Test valid chat request creation.""" req = ChatReq( @@ -74,7 +74,7 @@ def test_valid_chat_request(self): assert req.stream is False assert req.max_tokens == 1024 assert req.temperature == 0.0 - + def test_empty_model_name(self): """Test empty model name is rejected.""" with pytest.raises(ValidationError) as exc_info: @@ -83,7 +83,7 @@ def test_empty_model_name(self): messages=[Message(role="user", content="test")] ) assert "Model name cannot be empty" in str(exc_info.value) - + def test_whitespace_only_model_name(self): """Test whitespace-only model name is rejected.""" with pytest.raises(ValidationError) as exc_info: @@ -92,7 +92,7 @@ def test_whitespace_only_model_name(self): messages=[Message(role="user", content="test")] ) assert "Model name cannot be empty" in str(exc_info.value) - + def test_model_name_trimming(self): """Test model name is trimmed.""" req = ChatReq( @@ -100,7 +100,7 @@ def test_model_name_trimming(self): messages=[Message(role="user", content="test")] ) assert req.model == "gpt-5.2" - + def test_empty_messages_list(self): """Test empty messages list is rejected.""" with pytest.raises(ValidationError) as exc_info: @@ -108,20 +108,20 @@ def test_empty_messages_list(self): # Verify it's a ValidationError for the messages field assert exc_info.type == ValidationError assert "messages" in str(exc_info.value) - + def test_too_many_messages(self): """Test more than 100 messages is rejected.""" messages = [Message(role="user", content=f"msg {i}") for i in range(101)] with pytest.raises(ValidationError) as exc_info: ChatReq(model="gpt-5.2", messages=messages) assert "Maximum 100 messages allowed" in str(exc_info.value) - + def test_max_messages_allowed(self): """Test exactly 100 messages is allowed.""" messages = [Message(role="user", content=f"msg {i}") for i in range(100)] req = ChatReq(model="gpt-5.2", messages=messages) assert len(req.messages) == 100 - + def test_max_tokens_validation(self): """Test max_tokens must be within valid range.""" # Too low @@ -131,7 +131,7 @@ def test_max_tokens_validation(self): messages=[Message(role="user", content="test")], max_tokens=0 ) - + # Too high with pytest.raises(ValidationError): ChatReq( @@ -139,7 +139,7 @@ def test_max_tokens_validation(self): messages=[Message(role="user", content="test")], max_tokens=5000 ) - + # Valid range req = ChatReq( model="gpt-5.2", @@ -147,7 +147,7 @@ def test_max_tokens_validation(self): max_tokens=2048 ) assert req.max_tokens == 2048 - + def test_temperature_validation(self): """Test temperature must be within valid range.""" # Too low @@ -157,7 +157,7 @@ def test_temperature_validation(self): messages=[Message(role="user", content="test")], temperature=-0.1 ) - + # Too high with pytest.raises(ValidationError): ChatReq( @@ -165,7 +165,7 @@ def test_temperature_validation(self): messages=[Message(role="user", content="test")], temperature=2.1 ) - + # Valid range req = ChatReq( model="gpt-5.2", @@ -173,7 +173,7 @@ def test_temperature_validation(self): temperature=0.7 ) assert req.temperature == 0.7 - + def test_frequency_penalty_validation(self): """Test frequency_penalty must be within valid range.""" # Too low @@ -183,7 +183,7 @@ def test_frequency_penalty_validation(self): messages=[Message(role="user", content="test")], frequency_penalty=-2.1 ) - + # Too high with pytest.raises(ValidationError): ChatReq( @@ -191,7 +191,7 @@ def test_frequency_penalty_validation(self): messages=[Message(role="user", content="test")], frequency_penalty=2.1 ) - + # Valid range req = ChatReq( model="gpt-5.2", @@ -199,7 +199,7 @@ def test_frequency_penalty_validation(self): frequency_penalty=0.5 ) assert req.frequency_penalty == 0.5 - + def test_stream_flag(self): """Test stream flag can be set.""" req = ChatReq( @@ -208,7 +208,7 @@ def test_stream_flag(self): stream=True ) assert req.stream is True - + def test_optional_tools_parameter(self): """Test optional tools parameter.""" # Without tools @@ -217,7 +217,7 @@ def test_optional_tools_parameter(self): messages=[Message(role="user", content="test")] ) assert req.tools is None - + # With tools tools = [{"type": "function", "function": {"name": "test"}}] req = ChatReq( @@ -226,7 +226,7 @@ def test_optional_tools_parameter(self): tools=tools ) assert req.tools == tools - + def test_default_values(self): """Test default values are set correctly.""" req = ChatReq( @@ -238,7 +238,7 @@ def test_default_values(self): assert req.temperature == 0.0 assert req.frequency_penalty == 1 assert req.tools is None - + def test_multiple_messages(self): """Test multiple messages in conversation.""" messages = [ @@ -255,18 +255,18 @@ def test_multiple_messages(self): class TestEdgeCases: """Tests for edge cases and boundary conditions.""" - + def test_unicode_content(self): """Test unicode content is handled correctly.""" msg = Message(role="user", content="Hello 世界 🌍") assert msg.content == "Hello 世界 🌍" - + def test_long_content(self): """Test very long content is accepted.""" long_content = "x" * 10000 msg = Message(role="user", content=long_content) assert len(msg.content) == 10000 - + def test_special_characters_in_model(self): """Test model names with special characters.""" req = ChatReq( @@ -274,7 +274,7 @@ def test_special_characters_in_model(self): messages=[Message(role="user", content="test")] ) assert req.model == "llama-3.1-sonar-small-128k-online" - + def test_minimum_max_tokens(self): """Test minimum value for max_tokens.""" req = ChatReq( @@ -283,7 +283,7 @@ def test_minimum_max_tokens(self): max_tokens=1 ) assert req.max_tokens == 1 - + def test_maximum_max_tokens(self): """Test maximum value for max_tokens.""" req = ChatReq( @@ -292,7 +292,7 @@ def test_maximum_max_tokens(self): max_tokens=4096 ) assert req.max_tokens == 4096 - + def test_zero_temperature(self): """Test zero temperature (deterministic).""" req = ChatReq( @@ -301,7 +301,7 @@ def test_zero_temperature(self): temperature=0.0 ) assert req.temperature == 0.0 - + def test_max_temperature(self): """Test maximum temperature.""" req = ChatReq( diff --git a/tests/test_websocket.py b/tests/test_websocket.py index a515c50..62796dc 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -2,7 +2,6 @@ import os import pytest from fastapi.testclient import TestClient -from unittest.mock import patch, AsyncMock, MagicMock import json # Set test environment before importing app @@ -34,7 +33,7 @@ def test_websocket_connection_with_valid_key(): with client.websocket_connect(f"/ws?api_key={os.environ['BRIDGE_SECRET']}") as websocket: # Connection should be established assert websocket is not None - + # Try to receive welcome message if any # Note: Actual implementation may vary except Exception as e: @@ -52,7 +51,7 @@ def test_websocket_message_format(): ], "stream": True } - + # Validate format structure assert "model" in expected_format assert "messages" in expected_format @@ -70,12 +69,12 @@ async def test_websocket_streaming_response_structure(): 'data: {"choices": [{"delta": {"content": " world"}}]}\n\n', 'data: [DONE]\n\n' ] - + # Verify chunk format for chunk in mock_chunks[:-1]: # Exclude [DONE] assert chunk.startswith('data: ') assert chunk.endswith('\n\n') - + # Extract JSON json_str = chunk[6:-2] # Remove 'data: ' and '\n\n' data = json.loads(json_str) @@ -89,7 +88,7 @@ def test_websocket_error_handling(): "error": "Invalid model", "type": "error" } - + # Validate error format assert "error" in error_response assert "type" in error_response @@ -112,7 +111,7 @@ def test_websocket_message_size_limit(): "model": "gpt-5.2", "messages": [{"role": "user", "content": large_content}] } - + # Should be serializable json_str = json.dumps(message) assert len(json_str) > 10000 @@ -126,13 +125,13 @@ def test_websocket_multiple_messages(): {"role": "assistant", "content": "Hi"}, {"role": "user", "content": "How are you?"} ] - + request = { "model": "gpt-5.2", "messages": messages, "stream": True } - + # Should be valid format assert len(request["messages"]) == 4 assert request["stream"] is True @@ -153,7 +152,7 @@ def test_websocket_invalid_json(): """Test WebSocket handling of invalid JSON.""" # Invalid JSON should be rejected invalid_json = "not a json string" - + with pytest.raises(json.JSONDecodeError): json.loads(invalid_json) @@ -166,7 +165,7 @@ def test_websocket_missing_required_fields(): } # Would fail validation if sent - testing documentation assert "messages" in message_missing_model - + # Missing messages - document expected validation behavior message_missing_messages = { "model": "gpt-5.2" @@ -191,7 +190,7 @@ def test_websocket_reconnection(): # First connection with client.websocket_connect(f"/ws?api_key={os.environ['BRIDGE_SECRET']}") as ws1: ws1.close() - + # Second connection should work with client.websocket_connect(f"/ws?api_key={os.environ['BRIDGE_SECRET']}") as ws2: assert ws2 is not None @@ -204,7 +203,7 @@ def test_websocket_authentication_methods(): # Query parameter query_auth = f"/ws?api_key={os.environ['BRIDGE_SECRET']}" assert "api_key=" in query_auth - + # Header authentication could also be supported # This test documents expected auth methods @@ -218,7 +217,7 @@ def test_websocket_stream_parameter(): "stream": True } assert req_streaming["stream"] is True - + # Stream disabled (though unusual for WebSocket) req_no_stream = { "model": "gpt-5.2", @@ -236,7 +235,7 @@ def test_websocket_model_routing(): "messages": [{"role": "user", "content": "test"}] } assert not perplexity_req["model"].startswith("copilot-") - + # GitHub Copilot model copilot_req = { "model": "copilot-gpt-4", diff --git a/tests/test_z_config.py b/tests/test_z_config.py index c15a332..2c5fbc5 100644 --- a/tests/test_z_config.py +++ b/tests/test_z_config.py @@ -9,8 +9,6 @@ def test_has_github_copilot_with_key(): os.environ["GITHUB_COPILOT_API_KEY"] = "test-key" try: # Re-import to get fresh function - from config import has_github_copilot - # Clear the module cache to force reload import importlib import config importlib.reload(config) @@ -23,8 +21,6 @@ def test_has_github_copilot_with_key(): elif "GITHUB_COPILOT_API_KEY" in os.environ: del os.environ["GITHUB_COPILOT_API_KEY"] # Reload again to restore original state - import importlib - import config importlib.reload(config) @@ -63,7 +59,7 @@ def test_validate_config_no_key(): import config importlib.reload(config) from config import validate_config - + with pytest.raises(ValueError, match="PERPLEXITY_API_KEY"): validate_config() finally: @@ -92,7 +88,7 @@ def test_validate_config_empty_key(): import config importlib.reload(config) from config import validate_config - + with pytest.raises(ValueError, match="cannot be empty"): validate_config() finally: