Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
213 lines
9.1 KiB
Python
213 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())
|
|
t0 = datetime.fromtimestamp(started, timezone.utc).isoformat()
|
|
root = langfuse.trace(tid, "fabrix", tags=["gateway"], metadata={"parts": kinds}, start=t0, end=langfuse.now_iso())
|
|
langfuse.send_later([
|
|
root,
|
|
langfuse.generation(
|
|
tid, "fabrix.chat", model=model_id, parent=root,
|
|
startTime=t0, 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")
|