mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
* Lock dashboard login to GitHub org members Add an org-membership gate to the dashboard OAuth callback. After resolving the GitHub login, enforce_org_login_gate(login) checks the existing ALLOWED_GITHUB_ORGS allowlist before issuing a session. - Reuses ALLOWED_GITHUB_ORGS (no new config knob) and is_user_active_org_member (installation-token check, so no extra OAuth scope and private memberships are visible). - Fail-open when unset/blank so existing deployments keep working; fail-closed on API errors. - Gate runs before the session cookie/token is persisted. Adds unit tests and documents the behavior in INSTALLATION.md. * docs: document Organization Members permission required for org login gate
650 lines
21 KiB
Python
650 lines
21 KiB
Python
"""FastAPI router for the dashboard backend."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import RedirectResponse, Response, StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
from .admin import is_admin
|
|
from .analyzer_cron import remove_continual_cron
|
|
from .enabled_repos import (
|
|
list_enabled_review_repos,
|
|
set_review_repo_enabled,
|
|
)
|
|
from .oauth import (
|
|
COOKIE_NAME,
|
|
SESSION_TTL_SECONDS,
|
|
STATE_COOKIE_NAME,
|
|
STATE_TTL_SECONDS,
|
|
decode_state,
|
|
enforce_org_login_gate,
|
|
exchange_code,
|
|
fetch_github_user,
|
|
hash_state_nonce,
|
|
issue_session,
|
|
issue_state,
|
|
new_state_nonce,
|
|
require_session,
|
|
sanitize_redirect_to,
|
|
)
|
|
from .options import SUPPORTED_MODELS
|
|
from .profiles import (
|
|
ProfileUpdate,
|
|
get_profile,
|
|
get_valid_access_token,
|
|
list_profiles,
|
|
upsert_access_token_from_github_response,
|
|
upsert_profile,
|
|
)
|
|
from .review_style_jobs import (
|
|
cancel_review_style_analysis,
|
|
start_bootstrap_analysis,
|
|
sync_review_style_run_status,
|
|
)
|
|
from .review_styles import (
|
|
ReviewStyleCreate,
|
|
ReviewStylePromptUpdate,
|
|
create_review_style,
|
|
delete_review_style,
|
|
get_review_style,
|
|
list_review_styles,
|
|
normalize_repo_full_name,
|
|
set_custom_prompt,
|
|
)
|
|
from .team_settings import (
|
|
TeamSettingsUpdate,
|
|
get_team_settings,
|
|
upsert_team_settings,
|
|
)
|
|
from .thread_api import (
|
|
ThreadCreateBody,
|
|
ThreadMessageBody,
|
|
cancel_dashboard_thread,
|
|
create_dashboard_thread,
|
|
delete_dashboard_thread,
|
|
get_dashboard_thread,
|
|
list_dashboard_threads,
|
|
send_dashboard_message,
|
|
stream_dashboard_thread,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/dashboard/api", tags=["dashboard"])
|
|
|
|
|
|
def _require_admin(session: dict[str, Any]) -> dict[str, Any]:
|
|
if not is_admin(session.get("email")):
|
|
raise HTTPException(403, "admin only")
|
|
return session
|
|
|
|
|
|
_SESSION_DEP = Depends(require_session)
|
|
|
|
|
|
def _admin_session(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
|
|
return _require_admin(session)
|
|
|
|
|
|
_ADMIN_DEP = Depends(_admin_session)
|
|
|
|
|
|
def _api_base_url() -> str:
|
|
v = os.environ.get("DASHBOARD_API_BASE_URL", "").rstrip("/")
|
|
if not v:
|
|
raise HTTPException(500, "DASHBOARD_API_BASE_URL not configured")
|
|
return v
|
|
|
|
|
|
def _frontend_base_url() -> str:
|
|
v = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
|
|
if not v:
|
|
raise HTTPException(500, "DASHBOARD_BASE_URL not configured")
|
|
return v
|
|
|
|
|
|
def _set_session_cookie(response: Response, jwt_token: str) -> None:
|
|
response.set_cookie(
|
|
key=COOKIE_NAME,
|
|
value=jwt_token,
|
|
max_age=SESSION_TTL_SECONDS,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="none",
|
|
path="/",
|
|
)
|
|
|
|
|
|
def _set_state_cookie(response: Response, nonce: str) -> None:
|
|
# SameSite=Lax so GitHub's top-level redirect back to /auth/callback
|
|
# still presents this cookie; the cookie is single-purpose and lives
|
|
# only for the duration of one OAuth round-trip.
|
|
response.set_cookie(
|
|
key=STATE_COOKIE_NAME,
|
|
value=nonce,
|
|
max_age=STATE_TTL_SECONDS,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="lax",
|
|
path="/dashboard/api/auth",
|
|
)
|
|
|
|
|
|
def _clear_state_cookie(response: Response) -> None:
|
|
response.delete_cookie(
|
|
STATE_COOKIE_NAME, path="/dashboard/api/auth", samesite="lax", secure=True
|
|
)
|
|
|
|
|
|
@router.get("/auth/login")
|
|
async def auth_login(request: Request, redirect_to: str | None = None) -> RedirectResponse:
|
|
client_id = os.environ.get("GITHUB_APP_CLIENT_ID", "")
|
|
if not client_id:
|
|
raise HTTPException(500, "GITHUB_APP_CLIENT_ID not configured")
|
|
safe_redirect = sanitize_redirect_to(redirect_to) or _frontend_base_url()
|
|
|
|
nonce = new_state_nonce()
|
|
state = issue_state(redirect_to=safe_redirect, nonce_hash=hash_state_nonce(nonce))
|
|
redirect_uri = f"{_api_base_url()}/dashboard/api/auth/callback"
|
|
url = (
|
|
"https://github.com/login/oauth/authorize"
|
|
f"?client_id={client_id}"
|
|
f"&redirect_uri={redirect_uri}"
|
|
f"&state={state}"
|
|
)
|
|
response = RedirectResponse(url, status_code=302)
|
|
_set_state_cookie(response, nonce)
|
|
return response
|
|
|
|
|
|
@router.get("/auth/callback")
|
|
async def auth_callback(request: Request, code: str, state: str) -> RedirectResponse:
|
|
state_payload = decode_state(state)
|
|
state_nonce_hash = state_payload.get("nonce_hash")
|
|
cookie_nonce = request.cookies.get(STATE_COOKIE_NAME)
|
|
if (
|
|
not isinstance(state_nonce_hash, str)
|
|
or not cookie_nonce
|
|
or not hmac.compare_digest(hash_state_nonce(cookie_nonce), state_nonce_hash)
|
|
):
|
|
# Either the cookie went missing (different browser, expired,
|
|
# cookies blocked) or the state was issued for a different session.
|
|
raise HTTPException(400, "oauth state mismatch — please retry login")
|
|
|
|
redirect_to = sanitize_redirect_to(state_payload.get("redirect_to")) or _frontend_base_url()
|
|
|
|
token_data = await exchange_code(code)
|
|
access_token = token_data.get("access_token")
|
|
if not isinstance(access_token, str):
|
|
raise HTTPException(400, "oauth exchange missing access_token")
|
|
user, email = await fetch_github_user(access_token)
|
|
login = user.get("login")
|
|
if not login:
|
|
raise HTTPException(400, "could not resolve GitHub login")
|
|
|
|
await enforce_org_login_gate(login)
|
|
|
|
await upsert_access_token_from_github_response(login, email or "", token_data)
|
|
|
|
session_jwt = issue_session(login=login, email=email, avatar_url=user.get("avatar_url"))
|
|
response = RedirectResponse(redirect_to, status_code=302)
|
|
_set_session_cookie(response, session_jwt)
|
|
_clear_state_cookie(response)
|
|
return response
|
|
|
|
|
|
@router.post("/auth/logout")
|
|
async def auth_logout() -> Response:
|
|
response = Response(status_code=204)
|
|
response.delete_cookie(COOKIE_NAME, path="/", samesite="none", secure=True)
|
|
return response
|
|
|
|
|
|
@router.get("/me")
|
|
async def me(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
|
|
return {
|
|
"login": session["sub"],
|
|
"email": session.get("email"),
|
|
"avatar_url": session.get("avatar_url"),
|
|
"is_admin": is_admin(session.get("email")),
|
|
}
|
|
|
|
|
|
@router.get("/options")
|
|
async def options() -> dict[str, Any]:
|
|
return {"models": SUPPORTED_MODELS}
|
|
|
|
|
|
@router.get("/profile")
|
|
async def get_my_profile(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
profile = await get_profile(session["sub"])
|
|
return profile or {}
|
|
|
|
|
|
@router.put("/profile")
|
|
async def put_my_profile(
|
|
update: ProfileUpdate,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
update.validate_pairing()
|
|
return await upsert_profile(session["sub"], session.get("email") or "", update)
|
|
|
|
|
|
@router.get("/admin/profiles")
|
|
async def admin_list_profiles(
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> list[dict[str, Any]]:
|
|
return await list_profiles()
|
|
|
|
|
|
class AdminProfileUpdate(ProfileUpdate):
|
|
email: str | None = None
|
|
|
|
|
|
@router.put("/admin/profiles/{login}")
|
|
async def admin_put_profile(
|
|
login: str,
|
|
update: AdminProfileUpdate,
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> dict[str, Any]:
|
|
update.validate_pairing()
|
|
existing = await get_profile(login) or {}
|
|
email = update.email or existing.get("email") or ""
|
|
# Overlay only fields that were explicitly sent so the admin form (which
|
|
# only sends model/effort/repo) can't reset other fields the target user
|
|
# configured via My Settings / Cloud Agents to ProfileUpdate's defaults.
|
|
incoming = update.model_dump(exclude={"email"}, exclude_unset=True)
|
|
merged = {**existing, **incoming}
|
|
base = ProfileUpdate(
|
|
**{k: v for k, v in merged.items() if k in ProfileUpdate.model_fields},
|
|
)
|
|
return await upsert_profile(login, email, base)
|
|
|
|
|
|
@router.get("/team-settings")
|
|
async def api_get_team_settings(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await get_team_settings()
|
|
|
|
|
|
@router.put("/team-settings")
|
|
async def api_put_team_settings(
|
|
update: TeamSettingsUpdate,
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> dict[str, Any]:
|
|
return await upsert_team_settings(update)
|
|
|
|
|
|
class EnabledReviewRepoUpdate(BaseModel):
|
|
full_name: str
|
|
enabled: bool
|
|
|
|
|
|
@router.get("/enabled-review-repos")
|
|
async def api_list_enabled_review_repos(
|
|
_session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, list[str]]:
|
|
return {"repos": await list_enabled_review_repos()}
|
|
|
|
|
|
@router.put("/enabled-review-repos")
|
|
async def api_set_enabled_review_repo(
|
|
update: EnabledReviewRepoUpdate,
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> dict[str, list[str]]:
|
|
repos = await set_review_repo_enabled(update.full_name, update.enabled)
|
|
return {"repos": repos}
|
|
|
|
|
|
def _next_link_url(link_header: str | None) -> str | None:
|
|
if not link_header:
|
|
return None
|
|
# GitHub Link header is comma-separated: '<url>; rel="next", <url>; rel="last"'
|
|
for part in link_header.split(","):
|
|
segments = [s.strip() for s in part.split(";")]
|
|
if len(segments) >= 2 and 'rel="next"' in segments[1] and segments[0].startswith("<"):
|
|
return segments[0][1:-1]
|
|
return None
|
|
|
|
|
|
async def _paginate(
|
|
client: httpx.AsyncClient,
|
|
url: str,
|
|
*,
|
|
headers: dict[str, str],
|
|
items_key: str | None,
|
|
cap: int = 1000,
|
|
) -> list[dict[str, Any]]:
|
|
"""Follow ``Link: rel="next"`` until exhausted (or cap reached).
|
|
|
|
``items_key`` is the JSON key holding the list when the endpoint returns
|
|
a wrapper object (e.g. ``/user/installations`` returns
|
|
``{"total_count": N, "installations": [...]}``). When ``None`` the
|
|
response body itself is treated as the list.
|
|
"""
|
|
out: list[dict[str, Any]] = []
|
|
next_url: str | None = url
|
|
first = True
|
|
while next_url and len(out) < cap:
|
|
params = {"per_page": "100"} if first else None
|
|
r = await client.get(next_url, headers=headers, params=params)
|
|
if r.status_code == 401:
|
|
raise HTTPException(401, "github token expired, re-login required")
|
|
r.raise_for_status()
|
|
body = r.json()
|
|
page = body.get(items_key, []) if items_key else body
|
|
if isinstance(page, list):
|
|
out.extend(page)
|
|
next_url = _next_link_url(r.headers.get("Link"))
|
|
first = False
|
|
return out
|
|
|
|
|
|
@router.get("/repos")
|
|
async def list_repos(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
"""List repos where open-swe is installed and the user has access.
|
|
|
|
Paginates both ``/user/installations`` and per-installation
|
|
``/user/installations/{id}/repositories`` so users with multiple
|
|
installations or >30 accessible repos get the complete set.
|
|
"""
|
|
login = session["sub"]
|
|
token = await get_valid_access_token(login)
|
|
if not token:
|
|
raise HTTPException(401, "github token unavailable, re-login required")
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
installations = await _paginate(
|
|
client,
|
|
"https://api.github.com/user/installations",
|
|
headers=headers,
|
|
items_key="installations",
|
|
)
|
|
except HTTPException as exc:
|
|
if exc.status_code != 401:
|
|
raise
|
|
token = await get_valid_access_token(login, force_refresh=True)
|
|
if not token:
|
|
raise HTTPException(401, "github token expired, re-login required") from exc
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
installations = await _paginate(
|
|
client,
|
|
"https://api.github.com/user/installations",
|
|
headers=headers,
|
|
items_key="installations",
|
|
)
|
|
repositories: list[dict[str, Any]] = []
|
|
for inst in installations:
|
|
inst_id = inst.get("id")
|
|
if inst_id is None:
|
|
continue
|
|
try:
|
|
repos = await _paginate(
|
|
client,
|
|
f"https://api.github.com/user/installations/{inst_id}/repositories",
|
|
headers=headers,
|
|
items_key="repositories",
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except httpx.HTTPStatusError:
|
|
continue
|
|
repositories.extend(repos)
|
|
return {
|
|
"installations": [
|
|
{
|
|
"id": i.get("id"),
|
|
"account": (i.get("account") or {}).get("login"),
|
|
"account_type": (i.get("account") or {}).get("type"),
|
|
}
|
|
for i in installations
|
|
],
|
|
"repositories": [
|
|
{"full_name": r.get("full_name"), "private": r.get("private", False)}
|
|
for r in repositories
|
|
if r.get("full_name")
|
|
],
|
|
}
|
|
|
|
|
|
def _raise_for_github_repo_status(status_code: int) -> None:
|
|
if status_code == 401:
|
|
raise HTTPException(401, "github token expired, re-login required")
|
|
if status_code == 404:
|
|
raise HTTPException(404, "repository not found")
|
|
if status_code == 403:
|
|
raise HTTPException(403, "no access to this private repository")
|
|
if status_code != 200:
|
|
raise HTTPException(502, f"github API error ({status_code})")
|
|
|
|
|
|
async def _assert_repo_available_for_style_analysis(full_name: str, token: str) -> None:
|
|
"""Ensure the repo exists and is readable for style learning.
|
|
|
|
Public repositories are allowed without the GitHub App installed on them.
|
|
Private repositories require the authenticated user to have read access.
|
|
"""
|
|
full_name = normalize_repo_full_name(full_name)
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
owner, name = full_name.split("/", 1)
|
|
async with httpx.AsyncClient() as client:
|
|
r = await client.get(
|
|
f"https://api.github.com/repos/{owner}/{name}",
|
|
headers=headers,
|
|
)
|
|
_raise_for_github_repo_status(r.status_code)
|
|
body = r.json()
|
|
if body.get("private") is not True:
|
|
return
|
|
# Private repo: 200 from GitHub implies the user's token can read it.
|
|
|
|
|
|
async def _require_repo_access_for_user(login: str, full_name: str) -> str:
|
|
"""Verify the user can read ``full_name`` on GitHub; return a valid access token."""
|
|
token = await get_valid_access_token(login)
|
|
if not token:
|
|
raise HTTPException(401, "github token unavailable, re-login required")
|
|
try:
|
|
await _assert_repo_available_for_style_analysis(full_name, token)
|
|
except HTTPException as exc:
|
|
if exc.status_code != 401:
|
|
raise
|
|
token = await get_valid_access_token(login, force_refresh=True)
|
|
if not token:
|
|
raise HTTPException(401, "github token expired, re-login required") from exc
|
|
await _assert_repo_available_for_style_analysis(full_name, token)
|
|
return token
|
|
|
|
|
|
@router.get("/review-styles")
|
|
async def api_list_review_styles(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> list[dict[str, Any]]:
|
|
records = await list_review_styles()
|
|
out: list[dict[str, Any]] = []
|
|
for record in records:
|
|
if record.get("status") == "running":
|
|
synced = await sync_review_style_run_status(record["full_name"])
|
|
out.append(synced)
|
|
else:
|
|
out.append(record)
|
|
return out
|
|
|
|
|
|
@router.post("/review-styles")
|
|
async def api_create_review_style(
|
|
body: ReviewStyleCreate,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
await _require_repo_access_for_user(session["sub"], body.full_name)
|
|
return await create_review_style(body.full_name, session["sub"])
|
|
|
|
|
|
@router.get("/review-styles/{full_name:path}")
|
|
async def api_get_review_style(
|
|
full_name: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
full_name = normalize_repo_full_name(full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
raise HTTPException(404, "review style not found")
|
|
if record.get("status") == "running":
|
|
record = await sync_review_style_run_status(full_name)
|
|
return record
|
|
|
|
|
|
@router.put("/review-styles/{full_name:path}")
|
|
async def api_update_review_style_prompt(
|
|
full_name: str,
|
|
body: ReviewStylePromptUpdate,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
full_name = normalize_repo_full_name(full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
raise HTTPException(404, "review style not found")
|
|
await _require_repo_access_for_user(session["sub"], full_name)
|
|
return await set_custom_prompt(full_name, body.custom_prompt)
|
|
|
|
|
|
@router.post("/review-styles/{full_name:path}/analyze")
|
|
async def api_analyze_review_style(
|
|
full_name: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
full_name = normalize_repo_full_name(full_name)
|
|
token = await _require_repo_access_for_user(session["sub"], full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
record = await create_review_style(full_name, session["sub"])
|
|
if record.get("status") == "running":
|
|
record = await sync_review_style_run_status(full_name)
|
|
if record.get("status") == "running":
|
|
raise HTTPException(409, "analysis already running")
|
|
return await start_bootstrap_analysis(
|
|
full_name,
|
|
github_token=token,
|
|
created_by=session["sub"],
|
|
)
|
|
|
|
|
|
@router.post("/review-styles/{full_name:path}/cancel")
|
|
async def api_cancel_review_style(
|
|
full_name: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
del session
|
|
full_name = normalize_repo_full_name(full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
raise HTTPException(404, "review style not found")
|
|
return await cancel_review_style_analysis(full_name)
|
|
|
|
|
|
@router.delete("/review-styles/{full_name:path}")
|
|
async def api_delete_review_style(
|
|
full_name: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> Response:
|
|
del session
|
|
full_name = normalize_repo_full_name(full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
raise HTTPException(404, "review style not found")
|
|
if record.get("status") == "running":
|
|
await cancel_review_style_analysis(full_name)
|
|
await remove_continual_cron(full_name)
|
|
await delete_review_style(full_name)
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.get("/threads")
|
|
async def api_list_threads(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> list[dict[str, Any]]:
|
|
return await list_dashboard_threads(session["sub"], email=session.get("email"))
|
|
|
|
|
|
@router.post("/threads")
|
|
async def api_create_thread(
|
|
body: ThreadCreateBody,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await create_dashboard_thread(session["sub"], body)
|
|
|
|
|
|
@router.get("/threads/{thread_id}")
|
|
async def api_get_thread(
|
|
thread_id: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await get_dashboard_thread(thread_id, session["sub"], email=session.get("email"))
|
|
|
|
|
|
@router.post("/threads/{thread_id}/messages")
|
|
async def api_send_thread_message(
|
|
thread_id: str,
|
|
body: ThreadMessageBody,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await send_dashboard_message(thread_id, session["sub"], body, email=session.get("email"))
|
|
|
|
|
|
@router.post("/threads/{thread_id}/cancel")
|
|
async def api_cancel_thread(
|
|
thread_id: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await cancel_dashboard_thread(thread_id, session["sub"], email=session.get("email"))
|
|
|
|
|
|
@router.delete("/threads/{thread_id}")
|
|
async def api_delete_thread(
|
|
thread_id: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> Response:
|
|
await delete_dashboard_thread(thread_id, session["sub"], email=session.get("email"))
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.get("/threads/{thread_id}/stream")
|
|
async def api_stream_thread(
|
|
thread_id: str,
|
|
request: Request,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> StreamingResponse:
|
|
last_event_id = request.headers.get("last-event-id")
|
|
|
|
async def event_generator():
|
|
async for chunk in stream_dashboard_thread(
|
|
thread_id, session["sub"], email=session.get("email"), last_event_id=last_event_id
|
|
):
|
|
yield chunk
|
|
|
|
return StreamingResponse(
|
|
event_generator(),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
|
)
|