-
Notifications
You must be signed in to change notification settings - Fork 218
Expand file tree
/
Copy pathapi.py
More file actions
370 lines (319 loc) · 13.2 KB
/
api.py
File metadata and controls
370 lines (319 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
import asyncio
import traceback
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, FastAPI, HTTPException
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from openhands.agent_server.bash_router import bash_router
from openhands.agent_server.config import (
Config,
get_default_config,
)
from openhands.agent_server.conversation_router import conversation_router
from openhands.agent_server.conversation_router_acp import conversation_router_acp
from openhands.agent_server.conversation_service import (
get_default_conversation_service,
)
from openhands.agent_server.dependencies import create_session_api_key_dependency
from openhands.agent_server.desktop_router import desktop_router
from openhands.agent_server.desktop_service import get_desktop_service
from openhands.agent_server.event_router import event_router
from openhands.agent_server.file_router import file_router
from openhands.agent_server.git_router import git_router
from openhands.agent_server.hooks_router import hooks_router
from openhands.agent_server.llm_router import llm_router
from openhands.agent_server.middleware import LocalhostCORSMiddleware
from openhands.agent_server.server_details_router import (
get_server_info,
mark_initialization_complete,
server_details_router,
)
from openhands.agent_server.skills_router import skills_router
from openhands.agent_server.sockets import sockets_router
from openhands.agent_server.tool_preload_service import get_tool_preload_service
from openhands.agent_server.tool_router import tool_router
from openhands.agent_server.vscode_router import vscode_router
from openhands.agent_server.vscode_service import get_vscode_service
from openhands.sdk.logger import DEBUG, get_logger
logger = get_logger(__name__)
@asynccontextmanager
async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
service = get_default_conversation_service()
vscode_service = get_vscode_service()
desktop_service = get_desktop_service()
tool_preload_service = get_tool_preload_service()
# Define async functions for starting each service
async def start_vscode_service():
if vscode_service is not None:
vscode_started = await vscode_service.start()
if vscode_started:
logger.info("VSCode service started successfully")
else:
logger.warning(
"VSCode service failed to start, continuing without VSCode"
)
else:
logger.info("VSCode service is disabled")
async def start_desktop_service():
if desktop_service is not None:
desktop_started = await desktop_service.start()
if desktop_started:
logger.info("Desktop service started successfully")
else:
logger.warning(
"Desktop service failed to start, continuing without desktop"
)
else:
logger.info("Desktop service is disabled")
async def start_tool_preload_service():
if tool_preload_service is not None:
tool_preload_started = await tool_preload_service.start()
if tool_preload_started:
logger.info("Tool preload service started successfully")
else:
logger.warning("Tool preload service failed to start - skipping")
else:
logger.info("Tool preload service is disabled")
# Start all services concurrently
results = await asyncio.gather(
start_vscode_service(),
start_desktop_service(),
start_tool_preload_service(),
return_exceptions=True,
)
# Check for any exceptions during initialization
exceptions = [r for r in results if isinstance(r, Exception)]
if exceptions:
logger.error(
"Service initialization failed with %d exception(s): %s",
len(exceptions),
exceptions,
)
# Re-raise the first exception to prevent server from starting
raise RuntimeError(
f"Server initialization failed with {len(exceptions)} exception(s)"
) from exceptions[0]
# Mark initialization as complete - now the /ready endpoint will return 200
# and Kubernetes readiness probes will pass
mark_initialization_complete()
logger.info("Server initialization complete - ready to serve requests")
async with service:
# Store the initialized service in app state for dependency injection
api.state.conversation_service = service
try:
yield
finally:
# Define async functions for stopping each service
async def stop_vscode_service():
if vscode_service is not None:
await vscode_service.stop()
async def stop_desktop_service():
if desktop_service is not None:
await desktop_service.stop()
async def stop_tool_preload_service():
if tool_preload_service is not None:
await tool_preload_service.stop()
# Stop all services concurrently
await asyncio.gather(
stop_vscode_service(),
stop_desktop_service(),
stop_tool_preload_service(),
return_exceptions=True,
)
def _get_root_path(config: Config) -> str:
root_path = ""
if config.web_url:
web_url = urlparse(config.web_url)
root_path = web_url.path.rstrip("/")
return root_path
def _create_fastapi_instance(config: Config) -> FastAPI:
"""Create the basic FastAPI application instance.
Returns:
Basic FastAPI application with title, description, and lifespan.
"""
return FastAPI(
title="OpenHands Agent Server",
description=(
"OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
),
lifespan=api_lifespan,
root_path=_get_root_path(config),
)
def _find_http_exception(exc: BaseExceptionGroup) -> HTTPException | None:
"""Helper function to find HTTPException in ExceptionGroup.
Args:
exc: BaseExceptionGroup to search for HTTPException.
Returns:
HTTPException if found, None otherwise.
"""
for inner_exc in exc.exceptions:
if isinstance(inner_exc, HTTPException):
return inner_exc
# Recursively search nested ExceptionGroups
if isinstance(inner_exc, BaseExceptionGroup):
found = _find_http_exception(inner_exc)
if found:
return found
return None
def _add_api_routes(app: FastAPI, config: Config) -> None:
"""Add all API routes to the FastAPI application.
Args:
app: FastAPI application instance to add routes to.
"""
app.include_router(server_details_router)
dependencies = []
if config.session_api_keys:
dependencies.append(Depends(create_session_api_key_dependency(config)))
api_router = APIRouter(prefix="/api", dependencies=dependencies)
api_router.include_router(event_router)
api_router.include_router(conversation_router)
api_router.include_router(conversation_router_acp)
api_router.include_router(tool_router)
api_router.include_router(bash_router)
api_router.include_router(git_router)
api_router.include_router(file_router)
api_router.include_router(vscode_router)
api_router.include_router(desktop_router)
api_router.include_router(skills_router)
api_router.include_router(hooks_router)
api_router.include_router(llm_router)
app.include_router(api_router)
app.include_router(sockets_router)
def _setup_static_files(app: FastAPI, config: Config) -> None:
"""Set up static file serving and root redirect if configured.
Args:
app: FastAPI application instance.
config: Configuration object containing static files settings.
"""
# Only proceed if static files are configured and directory exists
if not (
config.static_files_path
and config.static_files_path.exists()
and config.static_files_path.is_dir()
):
# Map the root path to server info if there are no static files
app.get("/", tags=["Server Details"])(get_server_info)
return
# Mount static files directory
app.mount(
"/static",
StaticFiles(directory=str(config.static_files_path)),
name="static",
)
# Add root redirect to static files
@app.get("/", tags=["Server Details"])
async def root_redirect():
"""Redirect root endpoint to static files directory."""
# Check if index.html exists in the static directory
# We know static_files_path is not None here due to the outer condition
assert config.static_files_path is not None
index_path = config.static_files_path / "index.html"
if index_path.exists():
return RedirectResponse(url="/static/index.html", status_code=302)
else:
return RedirectResponse(url="/static/", status_code=302)
def _add_exception_handlers(api: FastAPI) -> None:
"""Add exception handlers to the FastAPI application."""
@api.exception_handler(Exception)
async def _unhandled_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
"""Handle unhandled exceptions."""
# Always log that we're in the exception handler for debugging
logger.debug(
"Exception handler called for %s %s with %s: %s",
request.method,
request.url.path,
type(exc).__name__,
str(exc),
)
content = {
"detail": "Internal Server Error",
"exception": str(exc),
}
# In DEBUG mode, include stack trace in response
if DEBUG:
content["traceback"] = traceback.format_exc()
# Check if this is an HTTPException that should be handled directly
if isinstance(exc, HTTPException):
return await _http_exception_handler(request, exc)
# Check if this is a BaseExceptionGroup with HTTPExceptions
if isinstance(exc, BaseExceptionGroup):
http_exc = _find_http_exception(exc)
if http_exc:
return await _http_exception_handler(request, http_exc)
# If no HTTPException found, treat as unhandled exception
logger.error(
"Unhandled ExceptionGroup on %s %s",
request.method,
request.url.path,
exc_info=(type(exc), exc, exc.__traceback__),
)
return JSONResponse(status_code=500, content=content)
# Logs full stack trace for any unhandled error that FastAPI would
# turn into a 500
logger.error(
"Unhandled exception on %s %s",
request.method,
request.url.path,
exc_info=(type(exc), exc, exc.__traceback__),
)
return JSONResponse(status_code=500, content=content)
@api.exception_handler(HTTPException)
async def _http_exception_handler(
request: Request, exc: HTTPException
) -> JSONResponse:
"""Handle HTTPExceptions with appropriate logging."""
# Log 4xx errors at info level (expected client errors like auth failures)
if 400 <= exc.status_code < 500:
logger.info(
"HTTPException %d on %s %s: %s",
exc.status_code,
request.method,
request.url.path,
exc.detail,
)
# Log 5xx errors at error level with full traceback (server errors)
elif exc.status_code >= 500:
logger.error(
"HTTPException %d on %s %s: %s",
exc.status_code,
request.method,
request.url.path,
exc.detail,
exc_info=(type(exc), exc, exc.__traceback__),
)
content = {
"detail": "Internal Server Error",
"exception": str(exc),
}
if DEBUG:
content["traceback"] = traceback.format_exc()
# Don't leak internal details to clients for 5xx errors in production
return JSONResponse(
status_code=exc.status_code,
content=content,
)
# Return clean JSON response for all non-5xx HTTP exceptions
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
def create_app(config: Config | None = None) -> FastAPI:
"""Create and configure the FastAPI application.
Args:
config: Configuration object. If None, uses default config.
Returns:
Configured FastAPI application.
"""
if config is None:
config = get_default_config()
app = _create_fastapi_instance(config)
app.state.config = config
_add_api_routes(app, config)
_setup_static_files(app, config)
app.add_middleware(LocalhostCORSMiddleware, allow_origins=config.allow_cors_origins)
_add_exception_handlers(app)
return app
# Create the default app instance
api = create_app()