"""`/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")