Files
CODE_ASSISTANT/5_django_backend/apps/gateway/views.py
T

211 lines
9.1 KiB
Python

"""`/api/ito/*` — OpenCode 가 붙는 OpenAI 호환 중계. 본문은 손 안 대고 FabriX 로 흘림.
GET /api/ito/healthcheck 기동 확인(무인증)
GET /api/ito/models 설정된 모델 목록(OpenAI 형식)
POST /api/ito/chat/completions 스트림/비스트림 통과 중계
설정이 비어 있어도 서버는 뜸 — 이 엔드포인트만 503 으로 이유를 말함(다른 기능까지 죽이지 않게).
"""
from __future__ import annotations
import asyncio
import json
import time
import uuid
from datetime import datetime, timezone
import logging
from typing import AsyncIterator
import httpx
from django.conf import settings
from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse
from django.views.decorators.csrf import csrf_exempt
from . import langfuse
from .fabrix import FabrixConfig, Variant, auth_variants
log = logging.getLogger(__name__)
# 테스트가 httpx.MockTransport 를 꽂는 자리. None 이면 진짜 네트워크.
TRANSPORT: httpx.AsyncBaseTransport | None = None
# 마지막에 통과한 인증 형식 — 프로세스당 하나. 다음 요청은 이것부터 시도.
_last_ok: dict[str, Variant | None] = {"variant": None}
RETRY_PAUSE_S = 1.0 # 마지막 재시도 전 쉬는 시간
def _cfg() -> FabrixConfig:
return FabrixConfig.from_env(settings.FABRIX_ENV)
def _err(status: int, message: str, typ: str) -> JsonResponse:
"""OpenAI 형식 오류 — OpenCode 가 이 모양을 읽음(우리 envelope 아님)."""
return JsonResponse({"error": {"message": message, "type": typ}}, status=status)
def _key_rejected(request: HttpRequest, cfg: FabrixConfig) -> JsonResponse | None:
"""AAF_GATEWAY_KEY 가 있으면 Authorization: Bearer <키> 대조. 비우면 검사 안 함(로컬 전용)."""
if not cfg.gateway_key:
return None
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer ") and auth[7:].strip() == cfg.gateway_key:
return None
return _err(401, "게이트웨이 키가 틀려 — opencode.json 의 apiKey 와 AAF_GATEWAY_KEY 확인", "unauthorized")
def _client(cfg: FabrixConfig) -> httpx.AsyncClient:
timeout = httpx.Timeout(cfg.total_timeout_s, connect=cfg.connect_timeout_s, read=cfg.read_timeout_s)
return httpx.AsyncClient(timeout=timeout, transport=TRANSPORT)
def _sse_error(status: int, detail: bytes) -> bytes:
"""상류 오류를 SSE 오류 청크로. 연결은 정상 종료해 OpenCode 가 본문을 읽게."""
payload = {"error": {"message": detail.decode("utf-8", "replace")[:2000], "type": "upstream_error", "status": status}}
return b"data: " + json.dumps(payload, ensure_ascii=False).encode() + b"\n\ndata: [DONE]\n\n"
class _Collect:
"""상류 응답에서 답변 텍스트·usage 만 긁어 모음(관측용). 스트림은 줄 단위 `data: {json}`."""
def __init__(self) -> None:
self.text: list[str] = []
self.usage: dict | None = None
self.buf = b""
def feed(self, chunk: bytes) -> None:
self.buf += chunk
while b"\n" in self.buf:
line, self.buf = self.buf.split(b"\n", 1)
self._line(line.strip())
def _line(self, line: bytes) -> None:
if not line.startswith(b"data:") or line.endswith(b"[DONE]"):
return
try:
d = json.loads(line[5:])
except ValueError:
return
self.json(d)
def json(self, d: dict) -> None:
for c in d.get("choices") or []:
t = (c.get("delta") or c.get("message") or {}).get("content")
if isinstance(t, str):
self.text.append(t)
if d.get("usage"):
self.usage = d["usage"]
def _observe(model_id: str, body: dict, col: _Collect, started: float, status: int, *, kinds: list[str]) -> None:
if not langfuse.enabled():
return
u = col.usage or {}
tid = str(uuid.uuid4())
langfuse.send_later([
langfuse.trace(tid, "fabrix", tags=["gateway"], metadata={"parts": kinds}),
langfuse.generation(
tid, "fabrix.chat", model=model_id,
startTime=datetime.fromtimestamp(started, timezone.utc).isoformat(), endTime=langfuse.now_iso(),
input=body.get("messages"), output="".join(col.text),
usage=langfuse.usage_of(u.get("prompt_tokens"), u.get("completion_tokens")),
level="ERROR" if status >= 400 else "DEFAULT", statusMessage="" if status < 400 else f"upstream {status}",
modelParameters={k: v for k, v in body.items() if k in ("temperature", "max_completion_tokens", "max_tokens")},
metadata={"stream": bool(body.get("stream")), "tools": len(body.get("tools") or [])},
),
])
async def healthcheck(_request: HttpRequest) -> JsonResponse:
return JsonResponse({"success": True})
async def models(request: HttpRequest) -> HttpResponse:
cfg = _cfg()
if rejected := _key_rejected(request, cfg):
return rejected
data = [{"id": mid, "object": "model", "owned_by": "fabrix", "name": name} for mid, name in cfg.model_list().items()]
return JsonResponse({"object": "list", "data": data})
@csrf_exempt
async def chat_completions(request: HttpRequest) -> HttpResponse:
if request.method != "POST":
return _err(405, "POST 만 받아", "method_not_allowed")
cfg = _cfg()
if rejected := _key_rejected(request, cfg):
return rejected
if missing := cfg.missing():
return _err(503, f"FabriX 설정 누락: {', '.join(missing)} (.env 확인)", "configuration_error")
try:
payload = json.loads(request.body.decode("utf-8"))
assert isinstance(payload, dict)
except Exception:
return _err(400, "본문이 JSON 객체가 아니야", "invalid_request")
body, headers = cfg.prepare(payload)
# 어떤 파트가 왔고 어느 모델로 가는지 — 이미지 분기 확인용(본문은 안 찍음)
kinds = sorted({p.get("type", "?") for m in payload.get("messages") or [] for p in (m.get("content") if isinstance(m.get("content"), list) else [])})
log.info("ito → model=%s parts=%s stream=%s", headers.get("x-llm-model-id"), kinds or ["text"], bool(body.get("stream")))
variants = auth_variants(headers, _last_ok["variant"])
# FabriX 가 맞는 조합에도 가끔 401 을 뱉음(2026-09-21 고객사 실측: curl 10번 중 1~2번).
# 4개 조합 다 돌고 나서 제일 유력한 조합(맨 앞)을 잠깐 쉬고 한 번 더 — 그래도 401 이면 진짜 인증 문제
variants = variants + variants[:1]
model_id = headers.get("x-llm-model-id", "")
started = time.time()
col = _Collect()
if body.get("stream"):
async def gen() -> AsyncIterator[bytes]:
async with _client(cfg) as client:
resp: httpx.Response | None = None
vkey: Variant | None = None
for i, (vkey, vh) in enumerate(variants):
req = client.build_request("POST", cfg.url, headers=vh, json=body)
resp = await client.send(req, stream=True)
log.info("ito ← %s (auth %s/%s: %s)", resp.status_code, i + 1, len(variants), vkey)
if resp.status_code == 401 and i + 1 < len(variants):
await resp.aclose() # 인증 형식이 안 맞은 것 — 다른 조합으로 한 번 더
if i + 2 == len(variants):
await asyncio.sleep(RETRY_PAUSE_S)
continue
break
assert resp is not None
try:
if resp.status_code >= 400:
yield _sse_error(resp.status_code, await resp.aread())
return
_last_ok["variant"] = vkey
async for chunk in resp.aiter_raw():
col.feed(chunk)
yield chunk
finally:
await resp.aclose()
_observe(model_id, body, col, started, resp.status_code, kinds=kinds or ["text"])
out = StreamingHttpResponse(gen(), content_type="text/event-stream")
out["Cache-Control"] = "no-cache"
out["X-Accel-Buffering"] = "no"
return out
try:
async with _client(cfg) as client:
for i, (vkey, vh) in enumerate(variants):
resp = await client.post(cfg.url, headers=vh, json=body)
if resp.status_code == 401 and i + 1 < len(variants):
if i + 2 == len(variants):
await asyncio.sleep(RETRY_PAUSE_S)
continue
if resp.status_code < 400:
_last_ok["variant"] = vkey
break
except httpx.HTTPError as e:
return _err(502, f"FabriX 호출 실패: {type(e).__name__}: {e}", "upstream_error")
if resp.status_code < 400:
try:
col.json(resp.json())
except ValueError:
pass
_observe(model_id, body, col, started, resp.status_code, kinds=kinds or ["text"])
return HttpResponse(resp.content, status=resp.status_code, content_type="application/json")