Files
lovdata-chat/session-manager/routes/proxy.py
Torbjørn Lindahl 9683cf280b fix: add SSE streaming proxy and robust make try startup
The SSE proxy was buffering the entire response body with a 30s read
timeout, causing 504s on the OpenCode /global/event stream. Add a
streaming path that detects SSE requests (by Accept header or /event
path) and returns a StreamingResponse with no read timeout.

Also fix the make try target to poll the health endpoint for Docker
readiness and wait for the container to reach running status before
opening the browser.
2026-02-16 00:38:57 +01:00

513 lines
17 KiB
Python

import os
import re
import time
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request, Response
from starlette.responses import StreamingResponse
import httpx
from session_manager import session_manager
from http_pool import make_http_request, stream_http_request
from logging_config import (
RequestContext,
log_request,
log_session_operation,
log_security_event,
)
router = APIRouter(tags=["proxy"])
ALL_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
def get_session_from_cookie(request: Request) -> str:
session_id = request.cookies.get("lovdata_session")
if not session_id:
raise HTTPException(
status_code=400,
detail="No active session - please access via /session/{id}/ first",
)
return session_id
@router.api_route("/global/{path:path}", methods=ALL_METHODS)
async def proxy_global_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"global/{path}")
@router.api_route("/assets/{path:path}", methods=ALL_METHODS)
async def proxy_assets_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"assets/{path}")
@router.api_route("/provider/{path:path}", methods=ALL_METHODS)
async def proxy_provider_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"provider/{path}")
@router.api_route("/provider", methods=ALL_METHODS)
async def proxy_provider_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "provider")
@router.api_route("/project", methods=ALL_METHODS)
async def proxy_project_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "project")
@router.api_route("/path", methods=ALL_METHODS)
async def proxy_path_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "path")
@router.api_route("/find/{path:path}", methods=ALL_METHODS)
async def proxy_find_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"find/{path}")
@router.api_route("/file", methods=ALL_METHODS)
async def proxy_file_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "file")
@router.api_route("/file/{path:path}", methods=ALL_METHODS)
async def proxy_file_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"file/{path}")
# Additional OpenCode API endpoints for root-path operation
@router.api_route("/project/{path:path}", methods=ALL_METHODS)
async def proxy_project_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"project/{path}")
@router.api_route("/agent", methods=ALL_METHODS)
async def proxy_agent_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "agent")
@router.api_route("/agent/{path:path}", methods=ALL_METHODS)
async def proxy_agent_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"agent/{path}")
@router.api_route("/config", methods=ALL_METHODS)
async def proxy_config_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "config")
@router.api_route("/config/{path:path}", methods=ALL_METHODS)
async def proxy_config_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"config/{path}")
@router.api_route("/model", methods=ALL_METHODS)
async def proxy_model_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "model")
@router.api_route("/model/{path:path}", methods=ALL_METHODS)
async def proxy_model_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"model/{path}")
@router.api_route("/thread/{path:path}", methods=ALL_METHODS)
async def proxy_thread_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"thread/{path}")
@router.api_route("/chat/{path:path}", methods=ALL_METHODS)
async def proxy_chat_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"chat/{path}")
@router.api_route("/tree", methods=ALL_METHODS)
async def proxy_tree_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "tree")
@router.api_route("/tree/{path:path}", methods=ALL_METHODS)
async def proxy_tree_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"tree/{path}")
@router.api_route("/conversation", methods=ALL_METHODS)
async def proxy_conversation_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "conversation")
@router.api_route("/conversation/{path:path}", methods=ALL_METHODS)
async def proxy_conversation_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"conversation/{path}")
# OpenCode session and communication endpoints for message sending
@router.api_route("/command", methods=ALL_METHODS)
async def proxy_command_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "command")
@router.api_route("/command/{path:path}", methods=ALL_METHODS)
async def proxy_command_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"command/{path}")
@router.api_route("/mcp", methods=ALL_METHODS)
async def proxy_mcp_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "mcp")
@router.api_route("/mcp/{path:path}", methods=ALL_METHODS)
async def proxy_mcp_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"mcp/{path}")
@router.api_route("/lsp", methods=ALL_METHODS)
async def proxy_lsp_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "lsp")
@router.api_route("/lsp/{path:path}", methods=ALL_METHODS)
async def proxy_lsp_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"lsp/{path}")
@router.api_route("/vcs", methods=ALL_METHODS)
async def proxy_vcs_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "vcs")
@router.api_route("/vcs/{path:path}", methods=ALL_METHODS)
async def proxy_vcs_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"vcs/{path}")
@router.api_route("/permission", methods=ALL_METHODS)
async def proxy_permission_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "permission")
@router.api_route("/permission/{path:path}", methods=ALL_METHODS)
async def proxy_permission_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"permission/{path}")
@router.api_route("/question", methods=ALL_METHODS)
async def proxy_question_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "question")
@router.api_route("/question/{path:path}", methods=ALL_METHODS)
async def proxy_question_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"question/{path}")
@router.api_route("/event", methods=ALL_METHODS)
async def proxy_event_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "event")
@router.api_route("/event/{path:path}", methods=ALL_METHODS)
async def proxy_event_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"event/{path}")
@router.api_route("/status", methods=ALL_METHODS)
async def proxy_status_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "status")
@router.api_route("/status/{path:path}", methods=ALL_METHODS)
async def proxy_status_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"status/{path}")
# OpenCode internal session endpoint (different from our container sessions)
# Must be defined BEFORE /session/{session_id}/{path} to match first
@router.api_route("/session", methods=ALL_METHODS)
async def proxy_internal_session_to_session(request: Request):
session_id = get_session_from_cookie(request)
# Proxy the request directly, preserving query params
path = "session"
return await proxy_to_session(request, session_id, path)
def _is_sse_request(request: Request, path: str) -> bool:
"""Detect SSE requests by Accept header or path convention."""
accept = request.headers.get("accept", "")
if "text/event-stream" in accept:
return True
# OpenCode uses /global/event and /event paths for SSE
if path == "event" or path.endswith("/event"):
return True
return False
@router.api_route("/session/{session_id}/{path:path}", methods=ALL_METHODS)
async def proxy_to_session(request: Request, session_id: str, path: str):
start_time = time.time()
with RequestContext():
log_request(
request.method,
f"/session/{session_id}/{path}",
200,
0,
operation="proxy_start",
session_id=session_id,
)
session = await session_manager.get_session(session_id)
if not session or session.status != "running":
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method,
f"/session/{session_id}/{path}",
404,
duration_ms,
session_id=session_id,
error="Session not found or not running",
)
raise HTTPException(
status_code=404, detail="Session not found or not running"
)
docker_host = os.getenv("DOCKER_HOST", "http://docker-daemon:2375")
parsed = urlparse(docker_host)
container_host = parsed.hostname or "docker-daemon"
container_url = f"http://{container_host}:{session.port}"
url = f"{container_url}/{path}"
if request.url.query:
url += f"?{request.url.query}"
body = await request.body()
headers = dict(request.headers)
headers.pop("host", None)
if session.auth_token:
headers["Authorization"] = f"Bearer {session.auth_token}"
headers["X-Session-Token"] = session.auth_token
headers["X-Session-ID"] = session.session_id
# --- SSE streaming path ---
if _is_sse_request(request, path):
return await _proxy_sse(request, session_id, path, url, headers, body, start_time)
# --- Buffered path (original behaviour) ---
try:
log_session_operation(
session_id, "proxy_request", method=request.method, path=path
)
response = await make_http_request(
method=request.method,
url=url,
headers=headers,
content=body,
)
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method,
f"/session/{session_id}/{path}",
response.status_code,
duration_ms,
session_id=session_id,
operation="proxy_complete",
)
log_security_event(
"proxy_access",
"info",
session_id=session_id,
method=request.method,
path=path,
status_code=response.status_code,
)
content = response.content
response_headers = dict(response.headers)
content_type = response.headers.get("content-type", "")
if "text/html" in content_type:
try:
html = content.decode("utf-8")
session_prefix = f"/session/{session_id}"
html = re.sub(r'src="/', f'src="{session_prefix}/', html)
html = re.sub(r'href="/', f'href="{session_prefix}/', html)
html = re.sub(r'content="/', f'content="{session_prefix}/', html)
content = html.encode("utf-8")
response_headers["content-length"] = str(len(content))
except UnicodeDecodeError:
pass
resp = Response(
content=content,
status_code=response.status_code,
headers=response_headers,
)
resp.set_cookie(
key="lovdata_session",
value=session_id,
httponly=True,
samesite="lax",
max_age=86400,
)
return resp
except httpx.TimeoutException as e:
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method,
f"/session/{session_id}/{path}",
504,
duration_ms,
session_id=session_id,
error="timeout",
)
log_security_event(
"proxy_timeout",
"warning",
session_id=session_id,
method=request.method,
path=path,
error=str(e),
)
raise HTTPException(
status_code=504, detail="Request to session container timed out"
)
except httpx.RequestError as e:
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method,
f"/session/{session_id}/{path}",
502,
duration_ms,
session_id=session_id,
error=str(e),
)
log_security_event(
"proxy_connection_error",
"error",
session_id=session_id,
method=request.method,
path=path,
error=str(e),
)
raise HTTPException(
status_code=502,
detail=f"Failed to connect to session container: {str(e)}",
)
async def _proxy_sse(
request: Request,
session_id: str,
path: str,
url: str,
headers: dict,
body: bytes,
start_time: float,
):
"""Proxy an SSE event stream without buffering."""
log_session_operation(
session_id, "proxy_sse_stream", method=request.method, path=path
)
# We need to keep the httpx stream context alive for the lifetime of the
# StreamingResponse. Starlette calls our async generator and only closes
# it when the client disconnects, so we enter the context manager inside
# the generator and exit on cleanup.
async def event_generator():
try:
async with stream_http_request(
method=request.method,
url=url,
headers=headers,
content=body,
) as upstream:
async for chunk in upstream.aiter_bytes():
yield chunk
except httpx.RequestError as e:
log_security_event(
"proxy_sse_error",
"error",
session_id=session_id,
method=request.method,
path=path,
error=str(e),
)
finally:
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method,
f"/session/{session_id}/{path}",
200,
duration_ms,
session_id=session_id,
operation="proxy_sse_complete",
)
resp = StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
resp.set_cookie(
key="lovdata_session",
value=session_id,
httponly=True,
samesite="lax",
max_age=86400,
)
return resp