Compare commits
5 Commits
3feedd5698
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a89f46e3d | |||
| 9683cf280b | |||
| fb2c1f0c60 | |||
| 217d41d680 | |||
| 991080ae2b |
36
Makefile
36
Makefile
@@ -29,14 +29,30 @@ logs:
|
|||||||
# Create a new session and display its info
|
# Create a new session and display its info
|
||||||
session:
|
session:
|
||||||
@echo "Creating new session..."
|
@echo "Creating new session..."
|
||||||
@curl -s -X POST http://localhost:8080/sessions | jq .
|
@curl -s -X POST http://localhost:8080/api/sessions | jq .
|
||||||
|
|
||||||
# Try the web interface - creates a session and opens it
|
# Try the web interface - starts stack, creates a session and opens it
|
||||||
try:
|
try: up
|
||||||
@echo "Creating session and opening web interface..."
|
@echo "Waiting for services to be ready (Docker daemon can take ~30s)..."
|
||||||
@SESSION_ID=$$(curl -s -X POST http://localhost:8080/sessions | jq -r '.session_id') && \
|
@for i in $$(seq 1 60); do \
|
||||||
echo "Session created: $$SESSION_ID" && \
|
STATUS=$$(curl -sf http://localhost:8080/api/health 2>/dev/null | jq -r '.docker // false') && \
|
||||||
echo "Opening http://localhost:8080/session/$$SESSION_ID" && \
|
[ "$$STATUS" = "true" ] && break; \
|
||||||
xdg-open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \
|
printf '.'; \
|
||||||
open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \
|
sleep 1; \
|
||||||
echo "Visit: http://localhost:8080/session/$$SESSION_ID"
|
done
|
||||||
|
@echo ""
|
||||||
|
@echo "Creating session..."
|
||||||
|
@SESSION_ID=$$(curl -s -X POST http://localhost:8080/api/sessions | jq -r '.session_id') && \
|
||||||
|
echo "Session $$SESSION_ID created, waiting for container to start..." && \
|
||||||
|
for i in $$(seq 1 30); do \
|
||||||
|
S=$$(curl -sf http://localhost:8080/api/sessions/$$SESSION_ID 2>/dev/null | jq -r '.status // "unknown"') && \
|
||||||
|
[ "$$S" = "running" ] && break; \
|
||||||
|
[ "$$S" = "error" ] && echo "Container failed to start" && exit 1; \
|
||||||
|
printf '.'; \
|
||||||
|
sleep 1; \
|
||||||
|
done && \
|
||||||
|
echo "" && \
|
||||||
|
echo "Opening http://localhost:8080/c/$$SESSION_ID" && \
|
||||||
|
(xdg-open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \
|
||||||
|
open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \
|
||||||
|
echo "Visit: http://localhost:8080/c/$$SESSION_ID")
|
||||||
|
|||||||
12
cypress.config.js
Normal file
12
cypress.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const { defineConfig } = require('cypress');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
e2e: {
|
||||||
|
supportFile: 'cypress/support/e2e.js',
|
||||||
|
specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
|
||||||
|
defaultCommandTimeout: 10000,
|
||||||
|
requestTimeout: 10000,
|
||||||
|
video: false,
|
||||||
|
screenshotOnRunFailure: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
21
cypress/e2e/api/health.cy.js
Normal file
21
cypress/e2e/api/health.cy.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
const api = () => Cypress.env('API_URL');
|
||||||
|
|
||||||
|
describe('Health API', () => {
|
||||||
|
it('GET /api/health returns status and required fields', () => {
|
||||||
|
cy.request(`${api()}/api/health`).then((response) => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('status');
|
||||||
|
expect(response.body.status).to.be.oneOf([
|
||||||
|
'healthy',
|
||||||
|
'degraded',
|
||||||
|
'unhealthy',
|
||||||
|
]);
|
||||||
|
expect(response.body).to.have.property('docker');
|
||||||
|
expect(response.body).to.have.property('active_sessions');
|
||||||
|
expect(response.body).to.have.property('timestamp');
|
||||||
|
expect(response.body).to.have.property('resource_limits');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
97
cypress/e2e/api/sessions.cy.js
Normal file
97
cypress/e2e/api/sessions.cy.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
const api = () => Cypress.env('API_URL');
|
||||||
|
|
||||||
|
describe('Sessions API', () => {
|
||||||
|
const createdSessions = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
createdSessions.splice(0).forEach((id) => {
|
||||||
|
cy.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${api()}/api/sessions/${id}`,
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions', () => {
|
||||||
|
it('returns 200 with an array', () => {
|
||||||
|
cy.request(`${api()}/api/sessions`).then((response) => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.be.an('array');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/sessions', () => {
|
||||||
|
it('creates a session with expected fields', () => {
|
||||||
|
cy.request('POST', `${api()}/api/sessions`).then((response) => {
|
||||||
|
expect(response.status).to.be.oneOf([200, 201]);
|
||||||
|
expect(response.body).to.have.property('session_id');
|
||||||
|
expect(response.body).to.have.property('auth_token');
|
||||||
|
expect(response.body).to.have.property('status');
|
||||||
|
createdSessions.push(response.body.session_id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id', () => {
|
||||||
|
it('returns the created session', () => {
|
||||||
|
cy.request('POST', `${api()}/api/sessions`).then((createRes) => {
|
||||||
|
createdSessions.push(createRes.body.session_id);
|
||||||
|
const id = createRes.body.session_id;
|
||||||
|
|
||||||
|
cy.request(`${api()}/api/sessions/${id}`).then((response) => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('session_id', id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for nonexistent session', () => {
|
||||||
|
cy.request({
|
||||||
|
url: `${api()}/api/sessions/nonexistent-id-000`,
|
||||||
|
failOnStatusCode: false,
|
||||||
|
}).then((response) => {
|
||||||
|
expect(response.status).to.eq(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/sessions/:id', () => {
|
||||||
|
it('deletes a session', () => {
|
||||||
|
cy.request('POST', `${api()}/api/sessions`).then((createRes) => {
|
||||||
|
const id = createRes.body.session_id;
|
||||||
|
|
||||||
|
cy.request('DELETE', `${api()}/api/sessions/${id}`).then(
|
||||||
|
(response) => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('message');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for nonexistent session', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${api()}/api/sessions/nonexistent-id-000`,
|
||||||
|
failOnStatusCode: false,
|
||||||
|
}).then((response) => {
|
||||||
|
expect(response.status).to.eq(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/cleanup', () => {
|
||||||
|
it('returns 200 with cleanup message', () => {
|
||||||
|
cy.request('POST', `${api()}/api/cleanup`).then((response) => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body)
|
||||||
|
.to.have.property('message')
|
||||||
|
.that.includes('Cleanup completed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
7
cypress/e2e/smoke.cy.js
Normal file
7
cypress/e2e/smoke.cy.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
describe('Cypress Infrastructure Smoke Test', () => {
|
||||||
|
it('should execute a basic assertion', () => {
|
||||||
|
expect(true).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
6
cypress/support/e2e.js
Normal file
6
cypress/support/e2e.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Cypress E2E support file
|
||||||
|
|
||||||
|
// Base URL for API tests. Override with CYPRESS_API_URL env var.
|
||||||
|
// Not set as Cypress baseUrl to avoid server-reachability checks
|
||||||
|
// that would block offline tests (smoke).
|
||||||
|
Cypress.env('API_URL', Cypress.env('API_URL') || 'http://localhost');
|
||||||
181
nginx/Caddyfile
181
nginx/Caddyfile
@@ -2,187 +2,16 @@
|
|||||||
# Using HTTP for local development (no SSL warnings)
|
# Using HTTP for local development (no SSL warnings)
|
||||||
|
|
||||||
# Main web interface - HTTP only for development
|
# Main web interface - HTTP only for development
|
||||||
http://localhost {
|
:80 {
|
||||||
# API endpoints for session management
|
# API endpoints for session management (strip /api prefix)
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
uri strip_prefix /api
|
uri strip_prefix /api
|
||||||
reverse_proxy session-manager:8000
|
reverse_proxy session-manager:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
# OpenCode internal session API (without session_id in path)
|
# Everything else goes to session-manager (handles /c/{id} entry
|
||||||
# Must be BEFORE /session/{session_id}* to match first
|
# point and cookie-based proxy to OpenCode containers)
|
||||||
handle /session {
|
handle {
|
||||||
reverse_proxy session-manager:8000
|
reverse_proxy session-manager:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
# Session-specific routing - proxy to session manager for dynamic routing
|
|
||||||
handle /session/{session_id}* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
# OpenCode SPA runtime requests - route based on session cookie
|
|
||||||
handle /global/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /assets/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /provider/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /provider {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /project {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /path {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /find/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /file {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /file/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Additional OpenCode API endpoints for root-path operation
|
|
||||||
handle /agent {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /agent/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /config {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /config/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /model {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /model/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /thread/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /chat/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /tree {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /tree/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /conversation {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /conversation/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /project/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
# OpenCode communication endpoints for message sending
|
|
||||||
handle /command {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /command/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /mcp {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /mcp/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /lsp {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /lsp/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /vcs {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /vcs/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /permission {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /permission/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /question {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /question/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /event {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /event/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /status {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /status/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
handle /health {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Static files and main interface (fallback)
|
|
||||||
handle /* {
|
|
||||||
try_files {path} {path}/ /index.html
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
2203
package-lock.json
generated
Normal file
2203
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "lovdata-chat",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A web-based chat interface that allows users to interact with Large Language Models (LLMs) equipped with Norwegian legal research tools from the Lovdata MCP server.",
|
||||||
|
"directories": {
|
||||||
|
"doc": "docs"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "cypress run",
|
||||||
|
"cypress:open": "cypress open",
|
||||||
|
"cypress:run": "cypress run"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@gitea.torbjorn.org:2222/torbjorn/lovdata-chat.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"cypress": "^15.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,24 @@ async def make_http_request(method: str, url: str, **kwargs) -> httpx.Response:
|
|||||||
return await client.request(method, url, **kwargs)
|
return await client.request(method, url, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def stream_http_request(method: str, url: str, **kwargs):
|
||||||
|
"""Stream an HTTP response using a dedicated client with no read timeout.
|
||||||
|
|
||||||
|
Yields an httpx.Response whose body has NOT been read -- caller must
|
||||||
|
iterate over ``response.aiter_bytes()`` / ``aiter_lines()`` etc.
|
||||||
|
|
||||||
|
A separate AsyncClient is used (not the pool) because httpx's
|
||||||
|
``stream()`` keeps the connection checked-out for the lifetime of the
|
||||||
|
context manager, and SSE streams are effectively infinite. Using a
|
||||||
|
short-lived client avoids starving the pool.
|
||||||
|
"""
|
||||||
|
timeout = httpx.Timeout(connect=10.0, read=None, write=10.0, pool=5.0)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
|
||||||
|
async with client.stream(method, url, **kwargs) as response:
|
||||||
|
yield response
|
||||||
|
|
||||||
|
|
||||||
async def get_connection_pool_stats() -> Dict[str, Any]:
|
async def get_connection_pool_stats() -> Dict[str, Any]:
|
||||||
"""Get connection pool statistics."""
|
"""Get connection pool statistics."""
|
||||||
return await _http_pool.get_pool_stats()
|
return await _http_pool.get_pool_stats()
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
|
from starlette.responses import RedirectResponse, StreamingResponse
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from session_manager import session_manager
|
from session_manager import session_manager
|
||||||
from http_pool import make_http_request
|
from http_pool import make_http_request, stream_http_request
|
||||||
from logging_config import (
|
from logging_config import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
log_request,
|
log_request,
|
||||||
@@ -25,276 +25,55 @@ def get_session_from_cookie(request: Request) -> str:
|
|||||||
if not session_id:
|
if not session_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="No active session - please access via /session/{id}/ first",
|
detail="No active session - please access via /c/{id} first",
|
||||||
)
|
)
|
||||||
return session_id
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
@router.api_route("/global/{path:path}", methods=ALL_METHODS)
|
@router.get("/c/{session_id}")
|
||||||
async def proxy_global_to_session(request: Request, path: str):
|
@router.get("/c/{session_id}/{path:path}")
|
||||||
|
async def enter_session(request: Request, session_id: str, path: str = ""):
|
||||||
|
"""Entry point: set session cookie and redirect to root."""
|
||||||
|
session = await session_manager.get_session(session_id)
|
||||||
|
if not session or session.status != "running":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="Session not found or not running"
|
||||||
|
)
|
||||||
|
resp = RedirectResponse(url="/", status_code=302)
|
||||||
|
resp.set_cookie(
|
||||||
|
key="lovdata_session",
|
||||||
|
value=session_id,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400,
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
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("/{path:path}", methods=ALL_METHODS)
|
||||||
|
async def proxy_root_to_container(request: Request, path: str):
|
||||||
|
"""Catch-all: proxy everything to the container identified by cookie."""
|
||||||
session_id = get_session_from_cookie(request)
|
session_id = get_session_from_cookie(request)
|
||||||
return await proxy_to_session(request, session_id, f"global/{path}")
|
return await _proxy_to_container(request, session_id, path)
|
||||||
|
|
||||||
|
|
||||||
@router.api_route("/assets/{path:path}", methods=ALL_METHODS)
|
async def _proxy_to_container(request: Request, session_id: str, path: str):
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@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()
|
start_time = time.time()
|
||||||
|
|
||||||
with RequestContext():
|
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)
|
session = await session_manager.get_session(session_id)
|
||||||
if not session or session.status != "running":
|
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(
|
raise HTTPException(
|
||||||
status_code=404, detail="Session not found or not running"
|
status_code=404, detail="Session not found or not running"
|
||||||
)
|
)
|
||||||
@@ -319,6 +98,11 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
|
|||||||
headers["X-Session-Token"] = session.auth_token
|
headers["X-Session-Token"] = session.auth_token
|
||||||
headers["X-Session-ID"] = session.session_id
|
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 ---
|
||||||
try:
|
try:
|
||||||
log_session_operation(
|
log_session_operation(
|
||||||
session_id, "proxy_request", method=request.method, path=path
|
session_id, "proxy_request", method=request.method, path=path
|
||||||
@@ -334,44 +118,17 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
|
|||||||
duration_ms = (time.time() - start_time) * 1000
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
log_request(
|
log_request(
|
||||||
request.method,
|
request.method,
|
||||||
f"/session/{session_id}/{path}",
|
f"/{path}",
|
||||||
response.status_code,
|
response.status_code,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
operation="proxy_complete",
|
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(
|
resp = Response(
|
||||||
content=content,
|
content=response.content,
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
headers=response_headers,
|
headers=dict(response.headers),
|
||||||
)
|
)
|
||||||
resp.set_cookie(
|
resp.set_cookie(
|
||||||
key="lovdata_session",
|
key="lovdata_session",
|
||||||
@@ -385,20 +142,8 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
|
|||||||
except httpx.TimeoutException as e:
|
except httpx.TimeoutException as e:
|
||||||
duration_ms = (time.time() - start_time) * 1000
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
log_request(
|
log_request(
|
||||||
request.method,
|
request.method, f"/{path}", 504, duration_ms,
|
||||||
f"/session/{session_id}/{path}",
|
session_id=session_id, error="timeout",
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=504, detail="Request to session container timed out"
|
status_code=504, detail="Request to session container timed out"
|
||||||
@@ -406,22 +151,66 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
|
|||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
duration_ms = (time.time() - start_time) * 1000
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
log_request(
|
log_request(
|
||||||
request.method,
|
request.method, f"/{path}", 502, duration_ms,
|
||||||
f"/session/{session_id}/{path}",
|
session_id=session_id, error=str(e),
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=502,
|
status_code=502,
|
||||||
detail=f"Failed to connect to session container: {str(e)}",
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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"/{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
|
||||||
|
|||||||
Reference in New Issue
Block a user