feat(backend): Django + OpenCode 백엔드 추가 — 5_django_backend/
앱이 기대하는 base-backend 계약(envelope·JWT·/chat/stream SSE)을 그대로 구현. Django 는 로그인·세션 미러(SQLite/PG)·OpenCode 이벤트 번역만 맡고, 답변은 전용 OpenCode 인스턴스(opencode/ workspace, codeassist 에이전트)가 만듦. - accounts: 이메일 로그인, access 60분 / refresh 14일, entra/config 는 501 - chat: 세션 목록·검색·메시지·취소 + POST /chat/stream 어댑터(part.delta→token, idle→usage/done, 클라 끊겨도 턴 감시 태스크가 DB 마무리, 첫 이벤트 60초 타임아웃) - OpenCode 1.18 멀티 프로젝트라 모든 요청에 ?directory= 부착 - docs-lib 에 OpenCode SDK 1.18.6 타입 원본, pytest 32 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
응답 envelope — 프론트 `types/api.ts` 의 CommonResponse 와 1:1.
|
||||
|
||||
{
|
||||
success, statusCode, code, message, data, counts, errors, timestamp, meta
|
||||
}
|
||||
|
||||
- DRF 뷰: 그냥 `Response(data)` 하면 EnvelopeRenderer 가 감쌈.
|
||||
목록은 `Response(paged(items, page, limit, total))` — data=items, meta 채워짐.
|
||||
- DRF 예외: envelope_exception_handler 가 success:false 로 감쌈. code 는 CodedError 로 지정.
|
||||
- 순수 Django async 뷰(스트림): `json_error()` 로 직접 만듦.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.http import JsonResponse
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
_LIST_MARK = "__paged__"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def envelope(
|
||||
*,
|
||||
success: bool,
|
||||
status_code: int,
|
||||
data=None,
|
||||
code: str | None = None,
|
||||
message: str | None = None,
|
||||
errors: list[str] | None = None,
|
||||
counts: int | None = None,
|
||||
meta: dict | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"success": success,
|
||||
"statusCode": status_code,
|
||||
"code": code,
|
||||
"message": message,
|
||||
"data": data,
|
||||
"counts": counts,
|
||||
"errors": errors or [],
|
||||
"timestamp": _now(),
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
|
||||
def paged(items: list, page: int, limit: int, total: int) -> dict:
|
||||
"""목록 응답용. Renderer 가 data=items, meta=페이지 정보로 풀어줌."""
|
||||
pages = (total + limit - 1) // limit if limit > 0 else 0
|
||||
return {
|
||||
_LIST_MARK: True,
|
||||
"items": items,
|
||||
"counts": total,
|
||||
"meta": {
|
||||
"currentPage": page,
|
||||
"pageSize": limit,
|
||||
"totalItems": total,
|
||||
"totalPages": pages,
|
||||
"hasNextPage": page < pages,
|
||||
"hasPreviousPage": page > 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _is_wrapped(data) -> bool:
|
||||
return isinstance(data, dict) and "success" in data and "statusCode" in data
|
||||
|
||||
|
||||
class EnvelopeRenderer(JSONRenderer):
|
||||
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||
response = (renderer_context or {}).get("response")
|
||||
status_code = getattr(response, "status_code", 200)
|
||||
if _is_wrapped(data):
|
||||
body = data
|
||||
elif isinstance(data, dict) and data.get(_LIST_MARK):
|
||||
body = envelope(
|
||||
success=True,
|
||||
status_code=status_code,
|
||||
data=data["items"],
|
||||
counts=data["counts"],
|
||||
meta=data["meta"],
|
||||
)
|
||||
else:
|
||||
body = envelope(success=True, status_code=status_code, data=data)
|
||||
return super().render(body, accepted_media_type, renderer_context)
|
||||
|
||||
|
||||
class CodedError(APIException):
|
||||
"""상태코드 + 프론트가 분기하는 code 문자열을 같이 던짐."""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str):
|
||||
super().__init__(detail=message)
|
||||
self.status_code = status_code
|
||||
self.code_name = code
|
||||
|
||||
|
||||
_DEFAULT_CODES = {
|
||||
400: "VALIDATION_ERROR",
|
||||
401: "UNAUTHORIZED",
|
||||
403: "FORBIDDEN",
|
||||
404: "NOT_FOUND",
|
||||
405: "METHOD_NOT_ALLOWED",
|
||||
409: "CONFLICT",
|
||||
501: "NOT_IMPLEMENTED",
|
||||
502: "UPSTREAM_ERROR",
|
||||
503: "UPSTREAM_UNAVAILABLE",
|
||||
}
|
||||
|
||||
|
||||
def _flatten(data) -> str:
|
||||
"""serializer 검증 오류(dict/list)를 한 줄로."""
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
if isinstance(data, list):
|
||||
return " ".join(_flatten(d) for d in data)
|
||||
if isinstance(data, dict):
|
||||
parts = []
|
||||
for k, v in data.items():
|
||||
msg = _flatten(v)
|
||||
parts.append(msg if k in ("detail", "non_field_errors") else f"{k}: {msg}")
|
||||
return " ".join(parts)
|
||||
return str(data)
|
||||
|
||||
|
||||
def envelope_exception_handler(exc, context):
|
||||
# rest_framework.views 는 settings 의 renderer(=이 모듈)를 import 하므로 여기서 늦게 import (순환 방지)
|
||||
from rest_framework.views import exception_handler
|
||||
|
||||
response = exception_handler(exc, context)
|
||||
if response is None:
|
||||
return None
|
||||
code = getattr(exc, "code_name", None) or _DEFAULT_CODES.get(response.status_code, "ERROR")
|
||||
message = _flatten(response.data)
|
||||
response.data = envelope(
|
||||
success=False,
|
||||
status_code=response.status_code,
|
||||
code=code,
|
||||
message=message,
|
||||
errors=[message],
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def json_error(status_code: int, code: str, message: str) -> JsonResponse:
|
||||
"""DRF 안 거치는 async 뷰용."""
|
||||
return JsonResponse(
|
||||
envelope(success=False, status_code=status_code, code=code, message=message, errors=[message]),
|
||||
status=status_code,
|
||||
json_dumps_params={"ensure_ascii": False},
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
===================================================================
|
||||
모듈: common/opencode_service.py
|
||||
설명: OpenCode 서버(opencode serve)와의 HTTP 통신
|
||||
|
||||
- JSON API (세션/메시지/프로바이더/에이전트) → 동기 httpx (DRF 뷰용)
|
||||
- SSE 이벤트 스트림 → 비동기 (SSE 뷰·파일 워처용)
|
||||
===================================================================
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# 메시지 전송은 LLM 응답 완료까지 걸리므로 타임아웃을 길게 잡는다 (10분)
|
||||
LONG_TIMEOUT = httpx.Timeout(600.0, connect=10.0)
|
||||
DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
||||
|
||||
|
||||
class OpencodeService:
|
||||
"""OpenCode 서버 프록시."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.OPENCODE_BASE_URL
|
||||
# 모든 요청에 ?directory= 를 붙여 CodeAssist workspace 프로젝트로 고정 (1.18 멀티 프로젝트 서버)
|
||||
self.directory = getattr(settings, "OPENCODE_DIRECTORY", "") or None
|
||||
|
||||
def _params(self) -> dict | None:
|
||||
return {"directory": self.directory} if self.directory else None
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
json: Any | None = None,
|
||||
timeout: httpx.Timeout = DEFAULT_TIMEOUT,
|
||||
) -> Any:
|
||||
with httpx.Client(base_url=self.base_url, timeout=timeout) as client:
|
||||
response = client.request(method, path, json=json, params=self._params())
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else None
|
||||
|
||||
# ── 세션
|
||||
def list_sessions(self) -> Any:
|
||||
return self._request("GET", "/session")
|
||||
|
||||
def create_session(self) -> Any:
|
||||
return self._request("POST", "/session", json={})
|
||||
|
||||
def delete_session(self, session_id: str) -> Any:
|
||||
return self._request("DELETE", f"/session/{session_id}")
|
||||
|
||||
def rename_session(self, session_id: str, title: str) -> Any:
|
||||
return self._request("PATCH", f"/session/{session_id}", json={"title": title})
|
||||
|
||||
# ── 메시지
|
||||
def list_messages(self, session_id: str) -> Any:
|
||||
return self._request("GET", f"/session/{session_id}/message")
|
||||
|
||||
def abort_session(self, session_id: str) -> Any:
|
||||
log.info("응답 중단: session=%s", session_id)
|
||||
return self._request("POST", f"/session/{session_id}/abort")
|
||||
|
||||
def send_message(self, session_id: str, payload: dict) -> Any:
|
||||
log.info(
|
||||
"메시지 전송: session=%s provider=%s model=%s",
|
||||
session_id, payload.get("providerID"), payload.get("modelID"),
|
||||
)
|
||||
# OpenCode 1.18+ 는 최상위 providerID/modelID 를 무시한다(세션 기본 모델 사용).
|
||||
# model:{providerID,modelID} 오브젝트 형식으로 변환해서 보내야 선택이 먹는다.
|
||||
provider_id = payload.pop("providerID", None)
|
||||
model_id = payload.pop("modelID", None)
|
||||
if provider_id and model_id:
|
||||
payload["model"] = {"providerID": provider_id, "modelID": model_id}
|
||||
return self._request(
|
||||
"POST", f"/session/{session_id}/message", json=payload, timeout=LONG_TIMEOUT
|
||||
)
|
||||
|
||||
# ── 설정
|
||||
def get_providers(self) -> Any:
|
||||
return self._request("GET", "/config/providers")
|
||||
|
||||
def list_agents(self) -> Any:
|
||||
return self._request("GET", "/agent")
|
||||
|
||||
# ── 비동기 버전 (스트림 어댑터용 — 이벤트 루프 안에서 블록 없이)
|
||||
async def _arequest(self, method: str, path: str, json: Any | None = None) -> Any:
|
||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=DEFAULT_TIMEOUT) as client:
|
||||
response = await client.request(method, path, json=json, params=self._params())
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else None
|
||||
|
||||
async def prompt_async(self, session_id: str, payload: dict) -> None:
|
||||
"""POST /session/{id}/prompt_async — 204 즉시 반환, 답변은 /event 로 흘러옴."""
|
||||
await self._arequest("POST", f"/session/{session_id}/prompt_async", json=payload)
|
||||
|
||||
async def get_session_a(self, session_id: str) -> Any:
|
||||
return await self._arequest("GET", f"/session/{session_id}")
|
||||
|
||||
async def list_messages_a(self, session_id: str) -> Any:
|
||||
return await self._arequest("GET", f"/session/{session_id}/message")
|
||||
|
||||
async def abort_session_a(self, session_id: str) -> Any:
|
||||
return await self._arequest("POST", f"/session/{session_id}/abort")
|
||||
|
||||
# ── SSE 이벤트 스트림 (비동기)
|
||||
async def iter_event_jsons(self) -> AsyncIterator[str | None]:
|
||||
"""SSE 프레임을 파싱해 이벤트 JSON 문자열 단위로 순회한다.
|
||||
|
||||
자가 치유: 업스트림이 60초간 조용하면 연결을 재수립한다 —
|
||||
서버 재시작 등으로 반쯤 죽은 소켓이 남아도 스스로 복구된다.
|
||||
무활동/재연결 시점에는 None(하트비트)을 내보낸다."""
|
||||
stream_timeout = httpx.Timeout(None, connect=10.0)
|
||||
while True:
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url, timeout=stream_timeout
|
||||
) as client:
|
||||
async with client.stream("GET", "/event", params=self._params()) as response:
|
||||
response.raise_for_status()
|
||||
data_lines: list[str] = []
|
||||
lines = response.aiter_lines()
|
||||
while True:
|
||||
try:
|
||||
line = await asyncio.wait_for(lines.__anext__(), timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
yield None # 60초 무활동 — 하트비트 후 재연결
|
||||
break
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
if line == "":
|
||||
if data_lines:
|
||||
yield "\n".join(data_lines)
|
||||
data_lines = []
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[5:].lstrip())
|
||||
# 그 외 필드(id:, event:, 코멘트)는 무시
|
||||
except Exception:
|
||||
log.warning("OpenCode SSE 업스트림 끊김 — 2초 후 재연결")
|
||||
yield None
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
opencode_service = OpencodeService()
|
||||
|
||||
|
||||
def upstream_error(e: Exception):
|
||||
"""OpenCode 서버 에러 → DRF APIException (FE 는 detail 필드를 읽는다)."""
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
if isinstance(e, httpx.ConnectError):
|
||||
exc = APIException("OpenCode 서버에 연결할 수 없습니다. opencode serve 실행 여부를 확인하세요.")
|
||||
exc.status_code = 503
|
||||
return exc
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
exc = APIException(f"OpenCode 서버 오류: {e.response.text[:500]}")
|
||||
exc.status_code = e.response.status_code
|
||||
return exc
|
||||
log.exception("OpenCode 중계 오류")
|
||||
exc = APIException(f"OpenCode 중계 오류: {e}")
|
||||
exc.status_code = 502
|
||||
return exc
|
||||
Reference in New Issue
Block a user