#!/usr/bin/env python3 """ Update OpenHands OpenAPI documentation. Generates the OpenAPI specification from the FastAPI application and writes it to docs/openapi.json. Usage: python scripts/update_openapi.py Behavior: - Uses openhands.server.app.app.openapi() to build the spec. - Preserves existing "servers" from docs/openapi.json if present; otherwise writes sensible defaults. - Sets info.version to openhands.__version__. - Sanitizes endpoint descriptions to remove code blocks and internal-only sections. - Excludes operational/UI-only convenience endpoints: - /server_info - /api/conversations/{conversation_id}/vscode-url - /api/conversations/{conversation_id}/web-hosts - Creates a backup docs/openapi.json.backup before overwriting. Output: - Prints OpenAPI and API versions, endpoint count, servers count, and sample endpoints. """ import json import logging import os import sys import warnings from pathlib import Path # Suppress warnings and logs during import logging.getLogger().setLevel(logging.CRITICAL) warnings.filterwarnings('ignore') os.environ['OPENHANDS_LOG_LEVEL'] = 'CRITICAL' # Add the project root to the Python path project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root)) try: from openhands import __version__ from openhands.server.app import app except ImportError as e: print(f'Error importing OpenHands modules: {e}') print( "Make sure you're running this script from the project root and dependencies are installed." ) sys.exit(1) def _sanitize_description(text: str) -> str: """Remove internal, code-centric, or redundant sections from endpoint descriptions. - Strip fenced code blocks - Remove Args/Returns/Raises/Example/Examples/Notes sections - Remove inline curl examples - Avoid provider-implementation specifics like LiteLLM/Bedrock """ import re if not text: return text # Remove fenced code blocks text = re.sub(r'```[\s\S]*?```', '', text, flags=re.MULTILINE) # Remove common docstring sections (until next blank line or end) for header in [ r'Args?:', r'Returns?:', r'Raises?:', r'Example[s]?:', r'Notes?:', ]: text = re.sub(rf'(?ms)^\s*{header}.*?(?:\n\s*\n|\Z)', '', text) # Remove lines that contain curl examples text = re.sub(r'(?im)^.*\bcurl\b.*$', '', text) # Generalize provider-implementation specifics text = re.sub(r'\bLiteLLM\b', 'configured model providers', text) text = re.sub(r'\blitellm\b', 'configured providers', text) text = re.sub(r'\bBedrock\b', '', text) # Collapse excessive blank lines and trim text = re.sub(r'\n{3,}', '\n\n', text).strip() return text def _sanitize_spec(spec: dict) -> dict: """Sanitize descriptions and summaries to be public-API friendly.""" path_summary_overrides = { '/api/options/models': 'List Supported Models', '/api/options/agents': 'List Agents', '/api/options/security-analyzers': 'List Security Analyzers', '/api/conversations/{conversation_id}/list-files': 'List Workspace Files', '/api/conversations/{conversation_id}/select-file': 'Get File Content', '/api/conversations/{conversation_id}/zip-directory': 'Download Workspace Archive', } path_description_overrides = { '/api/options/models': 'List model identifiers available on this server based on configured providers.', '/api/options/agents': 'List available agent types supported by this server.', '/api/options/security-analyzers': 'List supported security analyzers.', '/api/conversations/{conversation_id}/list-files': 'List workspace files visible to the conversation runtime. Applies .gitignore and internal ignore rules.', '/api/conversations/{conversation_id}/select-file': 'Return the content of the given file from the conversation workspace.', '/api/conversations/{conversation_id}/zip-directory': 'Return a ZIP archive of the current conversation workspace.', } for path, methods in list(spec.get('paths', {}).items()): for method, meta in list(methods.items()): if not isinstance(meta, dict): continue # Override overly specific summaries where helpful if path in path_summary_overrides: meta['summary'] = path_summary_overrides[path] # Override description if provided; otherwise sanitize if path in path_description_overrides: meta['description'] = path_description_overrides[path] elif 'description' in meta and isinstance(meta['description'], str): meta['description'] = _sanitize_description(meta['description']) return spec def generate_openapi_spec(): """Generate the OpenAPI specification from the FastAPI app.""" spec = app.openapi() # Explicitly exclude certain endpoints that are operational, experimental, or UI-only convenience excluded_endpoints = [ '/api/conversations/{conversation_id}/exp-config', # Internal experimentation endpoint '/server_info', # Operational/system diagnostics '/api/conversations/{conversation_id}/vscode-url', # UI/runtime convenience '/api/conversations/{conversation_id}/web-hosts', # UI/runtime convenience ] if 'paths' in spec: for endpoint in excluded_endpoints: if endpoint in spec['paths']: del spec['paths'][endpoint] print(f'Excluded endpoint: {endpoint}') # Sanitize descriptions and summaries spec = _sanitize_spec(spec) return spec def load_current_spec(spec_path): """Load the current OpenAPI specification if it exists.""" if spec_path.exists(): with open(spec_path, 'r') as f: return json.load(f) return {} def update_openapi_spec(spec_path, backup=True): """Update the OpenAPI specification file.""" # Generate new spec new_spec = generate_openapi_spec() # Load current spec for server information current_spec = load_current_spec(spec_path) # Preserve server information from current spec if it exists if 'servers' in current_spec: new_spec['servers'] = current_spec['servers'] else: # Default servers if none exist new_spec['servers'] = [ {'url': 'https://app.all-hands.dev', 'description': 'Production server'}, {'url': 'http://localhost:3000', 'description': 'Local server'}, ] # Update version to match the package version new_spec['info']['version'] = __version__ # Backup current file if requested if backup and spec_path.exists(): backup_path = spec_path.with_suffix('.json.backup') spec_path.rename(backup_path) print(f'Backed up current spec to {backup_path}') # Write new spec with open(spec_path, 'w') as f: json.dump(new_spec, f, indent=2) return new_spec def main(): """Main function.""" spec_path = project_root / 'docs' / 'openapi.json' print('Updating OpenAPI specification...') print(f'Target file: {spec_path}') try: new_spec = update_openapi_spec(spec_path) print('✅ Successfully updated OpenAPI specification!') print(f' OpenAPI version: {new_spec.get("openapi", "N/A")}') print(f' API version: {new_spec.get("info", {}).get("version", "N/A")}') print(f' Total endpoints: {len(new_spec.get("paths", {}))}') print(f' Servers: {len(new_spec.get("servers", []))}') # List some key endpoints paths = list(new_spec.get('paths', {}).keys()) if paths: print(' Sample endpoints:') for path in sorted(paths)[:5]: methods = list(new_spec['paths'][path].keys()) print(f' {path}: {methods}') if len(paths) > 5: print(f' ... and {len(paths) - 5} more') except Exception as e: print(f'❌ Error updating OpenAPI specification: {e}') sys.exit(1) if __name__ == '__main__': main()