"""`/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 json 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 .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} 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" 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"]) 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() # 인증 형식이 안 맞은 것 — 다른 조합으로 한 번 더 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(): yield chunk finally: await resp.aclose() 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): 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") return HttpResponse(resp.content, status=resp.status_code, content_type="application/json")