- apps/gateway: OpenCode→FabriX 통과 중계. 헤더 3종 인증, x-llm-model-id 로 모델 선택, 401 시 Bearer/날것×클라이언트 헤더 재시도, 모델 허용 목록. ABAP_OPENCODE apps_ito 통째 복사 대신 200줄로 - opencode/opencode.fabrix.json.tmpl + render_opencode.py — .env AAF_* 로 렌더 - .env.example AAF 블록(기본 605 Gemma4, TOKEN_PREFIX=bearer). 파서가 줄 끝 # 주석을 값으로 읽던 것 수정 - tests/test_gateway.py 9개(MockTransport) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12 KiB
CodeAssist 백엔드를 OpenCode + Django 로 — 분석과 인프라 제안
작성 2026-09-16. 코드 변경 전 분석 단계. 결정 5개 확정됨(6번). 다음은 /speckit-specify 로 specs/006-* 파고 여기 내용은 research 로 승계.
0. 한 줄 결론
프론트 계약은 그대로 두고, CodeAssist 전용 Django 를 새로 만든다. ABAP_OPENCODE web/BE 에서 인증·OpenCode 클라이언트·배포 뼈대를 복사해 오고, 그 위에 어댑터(envelope·세션 미러·스트림 변환)를 얹는다.
Django 가 OpenCode 서버(CodeAssist 전용 workspace)를 호출하고, OpenCode 이벤트를 프론트가 이미 아는 POST /chat/stream SSE(token/title/usage/done/error)로 바꿔준다.
프론트 변경은 env 한 줄 + 토큰 저장 두 줄.
1. 지금 상태 — 프론트가 백엔드에 기대하는 것
출처: 2_frontend/src/lib/api/client.ts, lib/streaming/streamLLM.ts, features/snap/api/*, features/auth/api/auth.api.ts, types/api.ts, specs/005-*/contracts/chat-stream.md, docs/superpowers/specs/2026-07-18-snap-backend-connect-design.md.
현재 백엔드는 별도 repo 의 FastAPI(base-backend, localhost:8001). 이 머신엔 없음.
| 영역 | 프론트가 기대하는 것 |
|---|---|
| 베이스 | VITE_API_BASE_URL (기본 /api/v1, 풀 URL 도 됨). dev 는 Vite proxy 로 same-origin |
| 응답 모양 | 전부 envelope CommonResponse{success,statusCode,code,message,data,counts,errors,timestamp,meta}. 헬퍼가 .data 언랩, apiList 는 meta{currentPage,pageSize,totalItems,totalPages,hasNextPage,hasPreviousPage} 로 페이지네이션. success:false 면 ApiError |
| 인증 | httpOnly 쿠키 + 401 → POST /auth/refresh 자동 재시도. Bearer seam 이미 있음 — lib/auth/tokenProvider.ts 에 토큰 넣으면 axios·SSE 둘 다 Authorization: Bearer 로 감 |
| 인증 API | POST /auth/login{email,password} → TokenResponse{token,refreshToken,…,user} · POST /auth/refresh · POST /auth/logout · GET /users/me · Entra GET /auth/entra/config (501 이면 버튼 숨김 — 다른 코드는 에러로 뜸) |
| 세션 | GET /chat/sessions?page&limit · POST /chat/sessions · GET /chat/sessions/search?query&page&limit (메시지 본문 ILIKE) · GET /chat/sessions/{id}/messages (isGenerating true 면 1.5초 폴링) · POST /chat/sessions/{id}/cancel |
| DTO | SnapSession{id,title,titleLlm,isGenerating,createdAt,updatedAt} · SnapMessage{sessionId,role,content,createdAt,inputTokens?,outputTokens?,costUsd?,elapsedMs?} |
| 스트림 | POST /chat/stream body {sessionId,content,images?[{mediaType,data(dataURL)}],forcedSkill?,explain?}. 요청 하나 = SSE 하나. token{delta} → usage{used,limit,ratio,elapsed_ms} → done{traceId?}, 중간 title{title}, 실패 error{message,code} |
| 스니펫 | 백엔드 안 씀. Tauri 브릿지 → 로컬 SQLite (결정 5: 유지) |
패키징 gotcha: Tauri 빌드는 dist 를 tauri:// 계열 origin 으로 띄움. /api/v1 상대경로는 dev(Vite proxy)에서만 되고 배포본은 풀 URL 이어야 함. cross-origin 이면 쿠키는 SameSite=None; Secure = https 필수인데 고객사 bare 배포가 http → Bearer 로 간다 (결정 2).
2. ABAP_OPENCODE web/BE 에서 복사해 올 것
출처: web/BE/code/README.md, common/opencode_service.py, apps/chat/sse.py, apps/accounts/*, deploy/*.
| 가져올 것 | 어디서 | 손볼 것 |
|---|---|---|
| Django+DRF+uvicorn(ASGI) 뼈대, settings 의 DB 스위치(SQLite↔PG), CORS | config/, requirements.txt |
앱 이름·포트만 |
JWT 인증 (simplejwt, Bearer 또는 ?token=) |
apps/accounts/ |
refresh 토큰 발급 추가, 응답을 TokenResponse 모양으로, 에러를 envelope 로 |
OpenCode HTTP 클라 + /event SSE 파서(60초 무활동 재연결) |
common/opencode_service.py |
그대로. base URL 만 env |
| 세션 owner 매핑 아이디어 | apps/ownership/ |
미러 테이블(5.3)로 흡수, 파일 소유권·워처는 안 가져옴 |
Dockerfile(be·opencode), compose, bare run.sh |
deploy/ |
서비스 3개(be·opencode-ca·mcp)로 줄임 |
FabriX 게이트웨이 apps_ito/aaf (OpenAI 호환 중계, 401 재시도) |
apps/gateway/ |
2026-09-17 작게 다시 씀(통째 복사 안 함). 그쪽은 11k 줄·DB 테이블·langgraph 등 의존성 15개인데 필요한 건 passthrough 300줄. 같은 규칙(헤더 3종·x-llm-model-id·401 형식 재시도·모델 허용 목록)만 fabrix.py+views.py 200줄로. 사용자별 키(IP 마스터)는 안 옮김 — 서비스 키 하나 |
sap-icf MCP 서버 |
web/MCP/code |
복사 안 하고 같은 VM 이면 기존 :3200 공유, 아니면 그때 복사 |
안 가져오는 것: apps/files(산출물), apps/usage, be-watcher, ABAP 스킬 4종, AGENTS.md(FS 브레인스토밍 규칙 — CodeAssist 랑 정반대).
3. 왜 이 안이냐 (대안 비교)
| A. 새 Django + 어댑터 (확정) | B. 프론트를 ABAP_OPENCODE 계약으로 | C. OpenCode 없이 OpenAI 호환 중계만 | |
|---|---|---|---|
| 프론트 변경 | env + 토큰 2줄 | envelope·인증·스트림 모델 대수술 | 스트림 어댑터 새로 |
| OpenCode 장점(MCP·세션 컨텍스트·에이전트) | 씀 | 씀 | 못 씀 — 요청 취지 밖 |
| 위험 | 이벤트 어댑터 품질, 복사본 관리 | 회귀 범위 큼(테스트 268개) | 단순하지만 요청과 다름 |
프론트는 "seam 만 바꾸면 되게" 짜여 있고(tokenProvider, streamLLM), Django 쪽 부품은 복사하면 되니 어댑터만 새로 짜면 됨.
4. 인프라 그림 (확정 반영)
[Windows PC] [서버 VM] ← CodeAssist 스택 (ABAP_OPENCODE 와 별개 프로세스)
CodeAssist.exe (Tauri) ┌─ nginx(:80) 또는 Django 직접(bare)
├─ WebView2: React dist │ /api/v1/* → codeassist-be
│ VITE_API_BASE_URL=http(s)://<서버>/api/v1 │
│ Authorization: Bearer <JWT> ───────────┼──▶ codeassist-be Django(uvicorn ASGI, :8001)
│ (토큰: localStorage — 결정 2) │ ├─ accounts JWT 발급·refresh (web/BE 복사)
│ │ ├─ chat 세션·검색·스트림 어댑터 + 미러 DB
│ │ └─ common/opencode_service.py (복사)
└─ Rust: 핫키·캡처·스니펫 SQLite(로컬) │
├─ opencode-ca(:4096) OpenCode 전용 인스턴스 (결정 4)
│ /workspace/AGENTS.md 짧은 코드 답변 규칙
│ /workspace/opencode.json 모델(dev OpenRouter / 고객사 FabriX), MCP sap-icf
│ /workspace/.opencode/agent/codeassist.md
├─ mcp sap-icf(:3200) 같은 VM 이면 ABAP_OPENCODE 것 공유
└─ db SQLite 파일(개발) / PostgreSQL(고객사) — DB_HOST 로 스위치
포트는 base-backend 가 쓰던 8001 그대로 → 프론트 .env 의 VITE_DEV_API_TARGET 도 안 바꿈. ABAP_OPENCODE 스택(8100/4096/3200)과 같은 VM 에 둘 땐 OpenCode 포트만 4097 로.
Django 코드 위치 (결정 1 = 새로): 이 repo 안 5_django_backend/ 로 제안. 이유: 프론트·Tauri·docs-lib·spec 이 다 여기라 계약 맞추기 쉽고, docs-lib/ 패턴도 그대로. 별도 repo 원하면 폴더째 떼면 됨 — 지금 결정 안 해도 됨.
5. 어댑터 — 뭘 만드나
5.1 응답 모양
- DRF Renderer + exception handler 로 전 응답
CommonResponseenvelope. 목록은meta채움. - 에러 코드 프론트가 아는 것 유지:
CHAT_GENERATION_IN_PROGRESS(409),LLM_ERROR,REFRESH_TOKEN_MISSING(401).
5.2 인증 (결정 2·3)
POST /auth/login→ simplejwt access+refresh 발급, 응답TokenResponse{token,refreshToken,tokenExpirationTime,…,user}.POST /auth/refreshbody{refreshToken}(쿠키 아님).POST /auth/logout은 refresh 블랙리스트.GET /users/me.GET /auth/entra/config→ 501 고정 (결정 3: Entra 안 함. 프론트가 버튼 숨김).entra/login라우트 자체 없음.- 프론트 변경 (유일): 로그인 응답
token/refreshToken을setAccessToken()+ localStorage 에. 401 인터셉터 refresh 를 Bearer 모드에서도 돌게(body 에 refreshToken). 지금// TODO(host)자리.
5.3 세션·메시지 — 미러 테이블
OpenCode 는 페이지네이션·본문 검색·isGenerating·메시지별 usage 를 안 줌 → Django 에 미러:
chat_session(id=opencode session id, user, title, title_llm, is_generating, created, updated)chat_message(session, role, content, created, input_tokens, output_tokens, cost_usd, elapsed_ms)- 스트림 시작: user 메시지 insert +
is_generating=true.session.idle: OpenCodeGET /session/{id}/message로 마지막 assistant 본문·tokens/cost가져와 insert,is_generating=false. - 목록 = 미러
-updated+ page/limit. 검색 = 미러icontains. 대화 컨텍스트 원본은 계속 OpenCode.
5.4 스트림 어댑터 POST /chat/stream
순수 Django async 뷰(ASGI):
- owner 확인,
is_generating이면 409. - 프로세스당 하나인 OpenCode
/event구독 태스크(복사한iter_event_jsons)에서 세션별asyncio.Queue로 fan-out. - 메시지 전송은 블록 안 되게 태스크로(
sync_to_async(send_message)또는 OpenCode 비동기 프롬프트 엔드포인트 — docs-lib 로 버전 확인).agent: "codeassist". - 큐:
message.part.delta(text) →event: token,session.updatedtitle →event: title,session.idle→usage→done. 에러 →event: error. - 이미지: OpenCode file 파트(mime + data URL). 파트 스키마 docs-lib 확인 후 구현.
- cancel → OpenCode
abort+is_generating=false. - 프론트가 스트림 잃어도
isGenerating폴링이 미러를 보니 답변 복구 — 기존 동작 그대로.
5.5 OpenCode workspace 5_django_backend/opencode/
AGENTS.md: 한국어, ABAP/SAP 코드 즉답, 코드펜스 언어 태그, 절차 질문 금지,explain이면 배경 설명.opencode.json: dev = OpenRouter, 고객사 = FabriX(게이트웨이 경유, 2차).tools.question=false. MCPsap-icf..opencode/agent/codeassist.md: 시스템 프롬프트.forcedSkill1차 무시.
5.6 배포
- compose:
be·opencode-ca·(옵션)mcp3개. 고객사 bare 는run.sh복사해 프로세스 2개. - 프론트
.env.production:VITE_API_BASE_URL=http(s)://<서버>/api/v1. 서버CORS_ORIGINS에 Tauri origin(tauri://localhost,http://tauri.localhost) 추가,CORS_ALLOW_HEADERS에 Authorization.
6. 결정 (2026-09-16 확정)
| # | 항목 | 결정 | 영향 |
|---|---|---|---|
| 1 | Django 위치 | 새로 만듦 (ABAP_OPENCODE 에 안 붙임) | accounts·opencode_service·deploy 복사. 게이트웨이(FabriX)는 고객사 갈 때 복사 |
| 2 | 토큰 저장 | localStorage (테스트 단계) | 프론트 2줄. 고객사 배포 전 Tauri 저장소로 옮길지 그때 결정 |
| 3 | Entra | 안 함 | entra/config 501 고정, 다른 Entra 코드 안 짬 |
| 4 | OpenCode | 분리 인스턴스 | 전용 workspace·AGENTS.md·포트 |
| 5 | 스니펫 | 로컬 SQLite 유지 | 백엔드 범위 밖 |
7. 단계 (spec 파면 이 순서로 tasks)
| 단계 | 내용 | 검증 |
|---|---|---|
| 0 | 5_django_backend/docs-lib/ 에 OpenCode 서버 API(세션·메시지 parts·event), DRF, simplejwt 문서 떨어뜨림 |
카탈로그 README |
| 1 | 뼈대 복사 + envelope renderer + accounts(login/refresh/logout/me) + entra 501 | 프론트 로그인 통과 |
| 2 | 미러 테이블 + sessions list/create/messages/search | 목록·검색 화면 |
| 3 | opencode-ca workspace + compose + OPENCODE_BASE_URL |
curl 로 세션 생성·프롬프트 |
| 4 | 스트림 어댑터 + cancel + isGenerating | Snap 스트리밍·중단·재진입 복구 |
| 5 | 이미지 파트 + explain |
캡처→질문 |
| 6 | 프론트 토큰 2줄 + .env.production + CORS |
Tauri 빌드 end-to-end |