diff --git a/5_django_backend/.env.example b/5_django_backend/.env.example index 83ca944..fcc71f8 100644 --- a/5_django_backend/.env.example +++ b/5_django_backend/.env.example @@ -15,3 +15,21 @@ DB_SCHEMA=codeassist # 프론트 origin (dev 는 Vite proxy 라 안 탐. Tauri 빌드용) CORS_ORIGINS=http://localhost:15173,http://tauri.localhost,tauri://localhost + +# ── 사내 LLM(FabriX) — 고객사 전용. 개발(OpenRouter)에선 전부 비워둠 ── +# OpenCode 는 opencode/opencode.json 을 FabriX 용으로 렌더해서(opencode/render_opencode.py) 이 서버의 /api/ito 로 붙음. +AAF_FABRIX_BASE_URL= +AAF_FABRIX_MODEL_ID=605 +AAF_FABRIX_MODELS=605:Gemma4,339:GaussO Flash,581:GaussO Think +AAF_FABRIX_CLIENT_KEY= +AAF_FABRIX_OPENAPI_TOKEN= +AAF_FABRIX_USER_EMAIL= +# 게이트웨이마다 다를 수 있는 것 — 보통 비워둠(401 나면 자동으로 다른 형식도 시도함) +AAF_FABRIX_CLIENT_HEADER= +AAF_FABRIX_TOKEN_PREFIX=bearer +AAF_FABRIX_MAX_TOKENS= +AAF_RELAY_STREAM_USAGE= +# OpenCode → 이 서버 사이 잠금. 같은 PC 안이면 비워도 됨. 서버를 바깥에 열면 반드시 랜덤 문자열 +AAF_GATEWAY_KEY= +# OpenCode 가 붙을 이 서버 포트 (render_opencode.py 가 씀) +BACKEND_PORT=8001 diff --git a/5_django_backend/README.md b/5_django_backend/README.md index 7436ce3..0020b96 100644 --- a/5_django_backend/README.md +++ b/5_django_backend/README.md @@ -75,3 +75,28 @@ opencode/ OpenCode workspace — AGENTS.md · opencode.json · .opencode/agen docs-lib/ OpenCode SDK 1.18.6 타입 (API 진실원천) tests/ pytest ``` + +## 고객사 = 사내 LLM(FabriX) + +개발은 OpenRouter 직결이고, 고객사에선 ABAP_OPENCODE 와 같은 방식으로 **FabriX** 를 씀. OpenCode 는 LLM 을 직접 안 부르고 이 서버의 `/api/ito`(OpenAI 호환) 로 붙고, `apps/gateway` 가 FabriX 인증 헤더를 얹어 그대로 흘림. + +``` +OpenCode ──OpenAI 호환──▶ Django /api/ito/chat/completions ──x-llm-model-id / x-openapi-token / x-generative-ai-client──▶ FabriX +``` + +```bash +# 1) .env 에 AAF_FABRIX_* 채움 (.env.example 의 사내 LLM 블록. 키 이름은 ABAP_OPENCODE 와 같음) +# 2) OpenCode 설정을 FabriX 용으로 렌더 (opencode/opencode.json 덮어씀) +.venv/bin/python opencode/render_opencode.py +# 3) OpenCode 는 키 없이 그냥 띄움 (OPENROUTER_API_KEY 불필요) +cd opencode && opencode serve --hostname 127.0.0.1 --port 4096 +# 4) 확인 +curl http://localhost:8001/api/ito/healthcheck +curl http://localhost:8001/api/ito/models +``` + +- `AAF_FABRIX_MODELS="605:Gemma4,339:GaussO Flash,581:GaussO Think"` 처럼 두면 OpenCode 화면에서 모델을 고를 수 있고, 고른 id 가 그대로 `x-llm-model-id` 로 감. +- 고객사 게이트웨이가 토큰 형식(Bearer/날것)·클라이언트 헤더 이름을 서버마다 다르게 받아서, 401 이면 다른 조합을 자동으로 더 시도하고 통과한 걸 기억함. +- 설정이 비어 있어도 서버는 뜨고, `/api/ito/chat/completions` 만 503 으로 뭐가 빠졌는지 알려줌. +- ABAP_OPENCODE 의 `apps_ito` 를 통째로 안 가져온 이유: 그쪽은 11k 줄 + DB 테이블 + langgraph 등 의존성 15개인데 여기 필요한 건 통과 중계뿐. 사용자별 키(IP 마스터)는 아직 없음 — 서비스 키 하나로 감. +- 개발로 되돌리기: `git checkout opencode/opencode.json`. diff --git a/5_django_backend/apps/gateway/__init__.py b/5_django_backend/apps/gateway/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/5_django_backend/apps/gateway/apps.py b/5_django_backend/apps/gateway/apps.py new file mode 100644 index 0000000..b880931 --- /dev/null +++ b/5_django_backend/apps/gateway/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GatewayConfig(AppConfig): + name = "apps.gateway" + verbose_name = "사내 LLM(FabriX) 게이트웨이" diff --git a/5_django_backend/apps/gateway/fabrix.py b/5_django_backend/apps/gateway/fabrix.py new file mode 100644 index 0000000..2f22bf8 --- /dev/null +++ b/5_django_backend/apps/gateway/fabrix.py @@ -0,0 +1,172 @@ +"""FabriX(사내 LLM, OpenAI 호환 Serving) 접속 규칙 — 순수 함수만. Django 안 봄. + +ABAP_OPENCODE `apps_ito/aaf` 의 passthrough 중계에서 CodeAssist 에 필요한 규칙만 옮겼음: + - 와이어는 OpenAI chat/completions 그대로. 인증은 Authorization 이 아니라 커스텀 헤더 3종. + - 실제 모델은 body 의 model 이 아니라 `x-llm-model-id` 헤더가 고름. body model 은 고정 경로. + - 고객사 게이트웨이가 토큰 형식(Bearer/날것)·클라이언트 헤더 이름을 서버마다 다르게 받아서, + 401 이면 다른 조합으로 한 번씩 더 찔러보고 통과한 조합을 기억함(2026-09-16 ABAP 쪽 실측). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace + +DEFAULT_BODY_MODEL = "/mnt/models" # body.model — 공식 예제 고정값. 모델 선택은 헤더가 함 +TOKEN_HDR = "x-openapi-token" +EMAIL_HDR = "x-generative-ai-user-email" +MODEL_HDR = "x-llm-model-id" +CLIENT_HDRS = ("x-generative-ai-client", "x-fabrix-client") # 고객사는 앞 것만 받음(뒤는 401) + +ENV_KEYS = ( + "AAF_FABRIX_BASE_URL", # …/openapi/llm 까지. 뒤에 /chat/completions 붙임 + "AAF_FABRIX_MODEL_ID", # 기본 모델 id (x-llm-model-id) + "AAF_FABRIX_MODELS", # "605:Gemma4,339:GaussO Flash,581:GaussO Think" — 화면에서 고를 수 있는 목록 + "AAF_FABRIX_CLIENT_KEY", + "AAF_FABRIX_OPENAPI_TOKEN", + "AAF_FABRIX_USER_EMAIL", + "AAF_FABRIX_CLIENT_HEADER", # 기본 x-generative-ai-client + "AAF_FABRIX_TOKEN_PREFIX", # "bearer" 면 토큰 앞에 "Bearer " 붙임. 기본은 날것 + "AAF_FABRIX_MODEL", # body.model. 기본 /mnt/models + "AAF_FABRIX_MAX_TOKENS", # 있으면 max_completion_tokens 로 실음 + "AAF_RELAY_STREAM_USAGE", # "1" 이면 stream_options.include_usage — 제공자가 거부하면 끔 + "AAF_GATEWAY_KEY", # OpenCode 가 Authorization: Bearer 로 보내는 키. 비우면 검사 안 함 +) + + +def parse_models(spec: str) -> dict[str, str]: + """"339:GaussO Flash,581:GaussO Think" → {"339": "GaussO Flash", "581": "GaussO Think"}.""" + out: dict[str, str] = {} + for item in (x.strip() for x in (spec or "").split(",")): + if not item: + continue + mid, _, name = item.partition(":") + if mid.strip(): + out[mid.strip()] = name.strip() or mid.strip() + return out + + +def token_value(token: str, prefix: str = "") -> str: + """x-openapi-token 값. 저장값에 Bearer 가 붙어 있어도 떼고, prefix=bearer 일 때만 다시 붙임.""" + t = (token or "").strip() + if t.lower().startswith("bearer "): + t = t[7:].strip() + return f"Bearer {t}" if t and prefix.strip().lower() == "bearer" else t + + +@dataclass(frozen=True) +class FabrixConfig: + base_url: str = "" + model_id: str = "" + client_key: str = "" + token: str = "" + email: str = "" + client_header: str = CLIENT_HDRS[0] + token_prefix: str = "" + body_model: str = DEFAULT_BODY_MODEL + models: dict[str, str] = field(default_factory=dict) + max_tokens: int | None = None + stream_usage: bool = False + gateway_key: str = "" + connect_timeout_s: float = 10.0 + read_timeout_s: float = 120.0 # 스트림 조각 사이 무수신 한계 + total_timeout_s: float = 600.0 + + @classmethod + def from_env(cls, env: dict[str, str]) -> "FabrixConfig": + g = lambda k: (env.get(k) or "").strip() # noqa: E731 + max_tokens = g("AAF_FABRIX_MAX_TOKENS") + return cls( + base_url=g("AAF_FABRIX_BASE_URL").rstrip("/"), + model_id=g("AAF_FABRIX_MODEL_ID"), + client_key=g("AAF_FABRIX_CLIENT_KEY"), + token=g("AAF_FABRIX_OPENAPI_TOKEN"), + email=g("AAF_FABRIX_USER_EMAIL"), + client_header=g("AAF_FABRIX_CLIENT_HEADER") or CLIENT_HDRS[0], + token_prefix=g("AAF_FABRIX_TOKEN_PREFIX"), + body_model=g("AAF_FABRIX_MODEL") or DEFAULT_BODY_MODEL, + models=parse_models(g("AAF_FABRIX_MODELS")), + max_tokens=int(max_tokens) if max_tokens.isdigit() else None, + stream_usage=g("AAF_RELAY_STREAM_USAGE") == "1", + gateway_key=g("AAF_GATEWAY_KEY"), + ) + + def missing(self) -> list[str]: + """비어 있으면 안 되는 설정 이름. 비어 있는 채로 부르면 정체불명 401/404 라 미리 막음.""" + need = { + "AAF_FABRIX_BASE_URL": self.base_url, + "AAF_FABRIX_MODEL_ID": self.model_id, + "AAF_FABRIX_CLIENT_KEY": self.client_key, + "AAF_FABRIX_OPENAPI_TOKEN": self.token, + } + return [k for k, v in need.items() if not v] + + @property + def url(self) -> str: + return self.base_url.rstrip("/") + "/chat/completions" + + def model_list(self) -> dict[str, str]: + """화면에 보여줄 모델 목록. AAF_FABRIX_MODELS 없으면 기본 모델 하나.""" + return dict(self.models) or {self.model_id: "FabriX"} + + def pick_model_id(self, requested: str) -> str: + """OpenCode 가 보낸 model 이 허용 목록에 있으면 그것, 아니면 기본 모델.""" + rm = (requested or "").strip() + return rm if rm and rm in self.models else self.model_id + + def headers(self, model_id: str | None = None) -> dict[str, str]: + return { + "Content-Type": "application/json", + MODEL_HDR: model_id or self.model_id, + self.client_header: self.client_key, + TOKEN_HDR: token_value(self.token, self.token_prefix), + EMAIL_HDR: self.email, # 빈 값이라도 실음 — 공식 예제가 그럼 + } + + def prepare(self, payload: dict) -> tuple[dict, dict[str, str]]: + """OpenCode 가 보낸 OpenAI 요청 → FabriX 로 보낼 (body, headers).""" + body = dict(payload) + headers = self.headers(self.pick_model_id(str(payload.get("model") or ""))) + body["model"] = self.body_model + if body.get("stream") and self.stream_usage: + so = dict(body.get("stream_options") or {}) + so["include_usage"] = True + body["stream_options"] = so + if self.max_tokens: + body.setdefault("max_completion_tokens", self.max_tokens) + return body, headers + + def with_models(self, models: dict[str, str]) -> "FabrixConfig": + return replace(self, models=models) + + +Variant = tuple[str, str | None] # (토큰 접두 "bearer"|"raw", 클라이언트 헤더 이름) + + +def auth_variants(headers: dict[str, str], last_ok: Variant | None = None) -> list[tuple[Variant, dict[str, str]]]: + """설정된 헤더에서 시작해 (Bearer/날것) × (클라이언트 헤더 이름) 조합을 만듦. + 순서: 마지막에 통과한 조합 → 설정된 조합 → 나머지. 토큰이 없으면 원본 하나만.""" + tok = headers.get(TOKEN_HDR) + if not tok: + return [(("raw", None), headers)] + raw = tok[7:].strip() if tok.lower().startswith("bearer ") else tok.strip() + client_val = next((headers[h] for h in CLIENT_HDRS if h in headers), None) + client_names: tuple[str | None, ...] = CLIENT_HDRS if client_val is not None else (None,) + + variants: dict[Variant, dict[str, str]] = {} + for prefix in ("bearer", "raw"): + for chdr in client_names: + h = {k: v for k, v in headers.items() if k not in CLIENT_HDRS} + h[TOKEN_HDR] = f"Bearer {raw}" if prefix == "bearer" else raw + if chdr: + h[chdr] = client_val or "" + variants[(prefix, chdr)] = h + + current: Variant = ( + "bearer" if tok.lower().startswith("bearer ") else "raw", + next((h for h in CLIENT_HDRS if h in headers), None), + ) + order: list[Variant] = [current] + if last_ok and last_ok in variants and last_ok != current: + order.insert(0, last_ok) + order += [k for k in variants if k not in order] + return [(k, variants[k]) for k in order] diff --git a/5_django_backend/apps/gateway/urls.py b/5_django_backend/apps/gateway/urls.py new file mode 100644 index 0000000..ae44bdc --- /dev/null +++ b/5_django_backend/apps/gateway/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from .views import chat_completions, healthcheck, models + +# 슬래시 변형도 같이 — POST 는 리다이렉트에 기대면 본문이 유실됨. +urlpatterns = [ + path("api/ito/healthcheck", healthcheck), + path("api/ito/healthcheck/", healthcheck), + path("api/ito/models", models), + path("api/ito/models/", models), + path("api/ito/chat/completions", chat_completions), + path("api/ito/chat/completions/", chat_completions), +] diff --git a/5_django_backend/apps/gateway/views.py b/5_django_backend/apps/gateway/views.py new file mode 100644 index 0000000..12ee1c9 --- /dev/null +++ b/5_django_backend/apps/gateway/views.py @@ -0,0 +1,128 @@ +"""`/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 +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 + +# 테스트가 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) + 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) + 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") diff --git a/5_django_backend/config/settings.py b/5_django_backend/config/settings.py index 69349f9..048bab3 100644 --- a/5_django_backend/config/settings.py +++ b/5_django_backend/config/settings.py @@ -25,6 +25,8 @@ def _load_dotenv(path: Path) -> None: if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) + # 줄 끝 주석(공백 뒤 #)은 값이 아님 — 안 떼면 HTTP 헤더에 한글이 실려 httpx 가 죽음(고객사 실측) + v = v.split(" #", 1)[0].split(" #", 1)[0] os.environ.setdefault(k.strip(), v.strip().strip('"\x27')) @@ -73,6 +75,12 @@ OPENCODE_BASE_URL = _env("OPENCODE_BASE_URL", "http://localhost:4096").rstrip("/ OPENCODE_DIRECTORY = _env("OPENCODE_DIRECTORY") or str((BASE_DIR / "opencode").resolve()) # OpenCode 에 넘길 에이전트 이름 (opencode/.opencode/agent/<이름>.md) OPENCODE_AGENT = _env("OPENCODE_AGENT", "codeassist") + +# 사내 LLM(FabriX) 게이트웨이 — 고객사에선 OpenCode 가 /api/ito 로 붙고 apps/gateway 가 FabriX 로 중계. +# 키 이름은 ABAP_OPENCODE 와 같음(AAF_*). 전부 비어 있으면 /api/ito/chat/completions 만 503 이고 나머진 멀쩡. +from apps.gateway.fabrix import ENV_KEYS as _FABRIX_KEYS # noqa: E402 + +FABRIX_ENV = {k: _env(k) for k in _FABRIX_KEYS} # 세션 컨텍스트 하드 한도 — usage.limit 로 프론트 게이지에 감 CONTEXT_LIMIT_TOKENS = int(_env("CONTEXT_LIMIT_TOKENS", "128000") or "128000") @@ -87,6 +95,7 @@ INSTALLED_APPS = [ "corsheaders", "apps.accounts", "apps.chat", + "apps.gateway", ] MIDDLEWARE = [ diff --git a/5_django_backend/config/urls.py b/5_django_backend/config/urls.py index bb95e78..f214dae 100644 --- a/5_django_backend/config/urls.py +++ b/5_django_backend/config/urls.py @@ -21,4 +21,5 @@ urlpatterns = [ path("api/v1/auth/", include("apps.accounts.urls")), path("api/v1/users/me", MeView.as_view()), path("api/v1/chat/", include("apps.chat.urls")), + path("", include("apps.gateway.urls")), # /api/ito/* — 사내 LLM(FabriX) 중계 ] diff --git a/5_django_backend/opencode/opencode.fabrix.json.tmpl b/5_django_backend/opencode/opencode.fabrix.json.tmpl new file mode 100644 index 0000000..6467bc2 --- /dev/null +++ b/5_django_backend/opencode/opencode.fabrix.json.tmpl @@ -0,0 +1,25 @@ +{ + "$schema": "https://opencode.ai/config.json", + "disabled_providers": ["opencode", "anthropic", "openrouter", "openai", "google"], + "provider": { + "gateway": { + "npm": "@ai-sdk/openai-compatible", + "name": "FabriX", + "options": { + "baseURL": "http://127.0.0.1:${BACKEND_PORT}/api/ito", + "apiKey": "${AAF_GATEWAY_KEY}" + }, + "models": ${AAF_FABRIX_MODELS_JSON} + } + }, + "model": "gateway/${AAF_FABRIX_DEFAULT_MODEL}", + "tools": { "question": false }, + "mcp": { + "sap-icf": { + "type": "remote", + "url": "{env:SAP_MCP_URL}", + "headers": { "Authorization": "Bearer {env:SAP_MCP_KEY}" }, + "enabled": false + } + } +} diff --git a/5_django_backend/opencode/render_opencode.py b/5_django_backend/opencode/render_opencode.py new file mode 100644 index 0000000..2e62b68 --- /dev/null +++ b/5_django_backend/opencode/render_opencode.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""고객사(FabriX)용 opencode.json 만들기 — 템플릿의 ${이름} 을 .env 값으로 채움. + + python opencode/render_opencode.py # ../.env 읽어서 opencode/opencode.json 덮어씀 + python opencode/render_opencode.py --check # 쓰진 않고 결과만 출력 + +개발(OpenRouter)로 돌아가려면 `git checkout opencode/opencode.json`. +ABAP_OPENCODE web/deploy/bare/render-opencode.py 와 같은 규칙: + AAF_FABRIX_MODELS="605:Gemma4,339:GaussO Flash,581:GaussO Think" → 모델 목록. 키가 곧 x-llm-model-id. + 비어 있으면 AAF_FABRIX_MODEL_ID 하나(이름 FabriX). 기본 모델은 목록의 첫 번째. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) +from apps.gateway.fabrix import parse_models # noqa: E402 + + +def load_env(path: Path) -> dict[str, str]: + """settings.py 와 같은 규칙의 아주 단순한 .env 파서. 이미 있는 환경변수가 우선.""" + out: dict[str, str] = {} + if path.exists(): + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + v = v.split(" #", 1)[0].strip().strip("\"'") # 줄 끝 주석은 두 칸 띄고 # + out[k.strip()] = v + out.update({k: v for k, v in os.environ.items() if k.startswith(("AAF_", "BACKEND_PORT"))}) + return out + + +def render(env: dict[str, str]) -> str: + models = parse_models(env.get("AAF_FABRIX_MODELS", "")) or { + (env.get("AAF_FABRIX_MODEL_ID") or "default"): "FabriX" + } + values = dict(env) + values["AAF_FABRIX_MODELS_JSON"] = json.dumps({mid: {"name": name} for mid, name in models.items()}, ensure_ascii=False) + values["AAF_FABRIX_DEFAULT_MODEL"] = next(iter(models)) + values.setdefault("BACKEND_PORT", "8001") + values["AAF_GATEWAY_KEY"] = values.get("AAF_GATEWAY_KEY") or "x" # SDK 가 빈 키를 거부해서 더미 + missing: list[str] = [] + + def fill(m: re.Match) -> str: + v = values.get(m.group(1), "") + if not v: + missing.append(m.group(1)) + return v + + text = re.sub(r"\$\{(\w+)\}", fill, (HERE / "opencode.fabrix.json.tmpl").read_text(encoding="utf-8")) + if missing: + print("경고: 비어 있는 값 —", ", ".join(sorted(set(missing))), file=sys.stderr) + return text + + +if __name__ == "__main__": + text = render(load_env(HERE.parent / ".env")) + if "--check" in sys.argv: + print(text) + else: + (HERE / "opencode.json").write_text(text, encoding="utf-8") + print("wrote", HERE / "opencode.json") diff --git a/5_django_backend/tests/test_gateway.py b/5_django_backend/tests/test_gateway.py new file mode 100644 index 0000000..a3ce855 --- /dev/null +++ b/5_django_backend/tests/test_gateway.py @@ -0,0 +1,137 @@ +"""/api/ito — OpenCode → FabriX 통과 중계.""" + +import json + +import httpx +import pytest +from django.test import AsyncClient, override_settings + +from apps.gateway import views +from apps.gateway.fabrix import FabrixConfig, auth_variants, parse_models, token_value + +FULL_ENV = { + "AAF_FABRIX_BASE_URL": "https://fabrix.test/openapi/llm/", + "AAF_FABRIX_MODEL_ID": "339", + "AAF_FABRIX_MODELS": "339:GaussO Flash,581:GaussO Think", + "AAF_FABRIX_CLIENT_KEY": "ck", + "AAF_FABRIX_OPENAPI_TOKEN": "Bearer tok", + "AAF_FABRIX_USER_EMAIL": "", +} + + +# ── 순수 규칙 ──────────────────────────────────────────────────── +def test_parse_models_and_token(): + assert parse_models("339:GaussO Flash, 581 , ,x:") == {"339": "GaussO Flash", "581": "581", "x": "x"} + assert token_value("Bearer abc") == "abc" + assert token_value("abc", "bearer") == "Bearer abc" + + +def test_prepare_picks_model_by_header_not_body(): + cfg = FabrixConfig.from_env({**FULL_ENV, "AAF_FABRIX_MAX_TOKENS": "4096", "AAF_RELAY_STREAM_USAGE": "1"}) + assert cfg.url == "https://fabrix.test/openapi/llm/chat/completions" + body, headers = cfg.prepare({"model": "581", "stream": True, "messages": []}) + assert body["model"] == "/mnt/models" and headers["x-llm-model-id"] == "581" + assert body["stream_options"] == {"include_usage": True} and body["max_completion_tokens"] == 4096 + assert headers["x-openapi-token"] == "tok" and headers["x-generative-ai-client"] == "ck" + # 목록에 없는 이름(더미)은 기본 모델 + _, headers = cfg.prepare({"model": "gpt-whatever"}) + assert headers["x-llm-model-id"] == "339" + + +def test_missing_config_names(): + assert FabrixConfig.from_env({}).missing() == [ + "AAF_FABRIX_BASE_URL", "AAF_FABRIX_MODEL_ID", "AAF_FABRIX_CLIENT_KEY", "AAF_FABRIX_OPENAPI_TOKEN", + ] + + +def test_auth_variants_order(): + headers = FabrixConfig.from_env(FULL_ENV).headers() + keys = [k for k, _ in auth_variants(headers)] + assert keys[0] == ("raw", "x-generative-ai-client") and len(keys) == 4 + # 마지막에 통과한 조합이 맨 앞으로 + keys2 = [k for k, _ in auth_variants(headers, ("bearer", "x-fabrix-client"))] + assert keys2[0] == ("bearer", "x-fabrix-client") and set(keys2) == set(keys) + hdrs = dict(auth_variants(headers))[("bearer", "x-fabrix-client")] + assert hdrs["x-openapi-token"] == "Bearer tok" and hdrs["x-fabrix-client"] == "ck" + assert "x-generative-ai-client" not in hdrs + + +# ── 뷰 (상류는 MockTransport) ──────────────────────────────────── +@pytest.fixture +def upstream(monkeypatch): + calls: list[httpx.Request] = [] + + def make(handler): + def _h(req: httpx.Request) -> httpx.Response: + calls.append(req) + return handler(req) + + monkeypatch.setattr(views, "TRANSPORT", httpx.MockTransport(_h)) + views._last_ok["variant"] = None + return calls + + return make + + +def _sse(data: bytes, status: int = 200) -> httpx.Response: + """상류 스트림 응답. content= 로 만들면 httpx 가 미리 읽어버려(StreamConsumed) stream= 으로.""" + return httpx.Response(status, stream=httpx.ByteStream(data)) + + +async def _post(body: dict, auth: str | None = None): + headers = {"Authorization": auth} if auth else {} + return await AsyncClient().post( + "/api/ito/chat/completions", data=json.dumps(body), content_type="application/json", headers=headers + ) + + +@override_settings(FABRIX_ENV=FULL_ENV) +async def test_stream_passthrough_bytes(upstream): + calls = upstream(lambda r: _sse(b'data: {"choices":[]}\n\ndata: [DONE]\n\n')) + resp = await _post({"model": "581", "stream": True, "messages": [{"role": "user", "content": "hi"}]}) + assert resp.status_code == 200 and resp["Content-Type"].startswith("text/event-stream") + assert b"".join([c async for c in resp.streaming_content]) == b'data: {"choices":[]}\n\ndata: [DONE]\n\n' + req = calls[0] + assert str(req.url) == "https://fabrix.test/openapi/llm/chat/completions" + assert req.headers["x-llm-model-id"] == "581" and json.loads(req.content)["model"] == "/mnt/models" + + +@override_settings(FABRIX_ENV=FULL_ENV) +async def test_401_retries_other_auth_shape_and_remembers(upstream): + def handler(req: httpx.Request) -> httpx.Response: + if req.headers.get("x-openapi-token") != "Bearer tok": + return httpx.Response(401, content=b"OIDC-TOKEN-0") + return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]}) + + calls = upstream(handler) + resp = await _post({"messages": []}) + assert resp.status_code == 200 and json.loads(resp.content)["choices"][0]["message"]["content"] == "ok" + assert calls[0].headers["x-openapi-token"] == "tok" and calls[-1].headers["x-openapi-token"] == "Bearer tok" + # 다음 요청은 통과한 형식부터 + n = len(calls) + await _post({"messages": []}) + assert len(calls) == n + 1 and calls[-1].headers["x-openapi-token"] == "Bearer tok" + + +@override_settings(FABRIX_ENV=FULL_ENV) +async def test_stream_upstream_error_becomes_sse_error(upstream): + upstream(lambda r: _sse(b"boom", 500)) + resp = await _post({"stream": True, "messages": []}) + out = b"".join([c async for c in resp.streaming_content]).decode() + assert resp.status_code == 200 and '"type": "upstream_error"' in out and "boom" in out and "[DONE]" in out + + +@override_settings(FABRIX_ENV={}) +async def test_missing_config_is_503_but_health_ok(): + resp = await _post({"messages": []}) + assert resp.status_code == 503 and "AAF_FABRIX_BASE_URL" in json.loads(resp.content)["error"]["message"] + assert (await AsyncClient().get("/api/ito/healthcheck")).status_code == 200 + + +@override_settings(FABRIX_ENV={**FULL_ENV, "AAF_GATEWAY_KEY": "secret"}) +async def test_gateway_key_and_models(upstream): + upstream(lambda r: httpx.Response(200, json={})) + assert (await _post({"messages": []})).status_code == 401 + assert (await _post({"messages": []}, auth="Bearer secret")).status_code == 200 + resp = await AsyncClient().get("/api/ito/models", headers={"Authorization": "Bearer secret"}) + assert [m["id"] for m in json.loads(resp.content)["data"]] == ["339", "581"] diff --git a/docs/feedback/2026-09-17-no-fabrix-call-from-dev-pc.md b/docs/feedback/2026-09-17-no-fabrix-call-from-dev-pc.md new file mode 100644 index 0000000..c51c982 --- /dev/null +++ b/docs/feedback/2026-09-17-no-fabrix-call-from-dev-pc.md @@ -0,0 +1,15 @@ +# 2026-09-17 사내 LLM(FabriX) 관련 호출은 이 PC 에서 하지 않는다 + +## 원문 + +> 이 피씨에서 호출해보지마 위험해 + +## 왜 틀렸나 + +FabriX 게이트웨이(`/api/ito`)를 붙인 뒤 "실제로 뜨는지" 확인하려고 개발 PC 에서 Django 를 재기동해 호출해 보려 했음. 사내 LLM 은 고객사 망·계정에 묶인 거라 개발 PC 에서 건드리면 안 됨. 검증은 단위 테스트(MockTransport)까지만. + +## 앞으로 + +- FabriX·`/api/ito`·AAF_* 설정이 관련된 건 **이 PC 에서 서버 띄워 호출하지 않음.** 테스트는 `tests/test_gateway.py` 처럼 상류를 mock 으로만. +- ABAP_OPENCODE 의 `.env` 에 있는 사내 키 값은 읽지도, 복사하지도 않음. 필요하면 키 이름만 봄. +- 고객사 환경 확인은 사용자가 직접 함. 나는 절차(README)와 확인 명령만 적어둠. diff --git a/docs/tech/opencode-django-backend.md b/docs/tech/opencode-django-backend.md index b923ae3..a9c18de 100644 --- a/docs/tech/opencode-django-backend.md +++ b/docs/tech/opencode-django-backend.md @@ -37,7 +37,7 @@ Django 가 OpenCode 서버(CodeAssist 전용 workspace)를 호출하고, OpenCod | 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_ito/` | **1차 안 가져옴.** 개발은 OpenRouter 직결. 고객사 갈 때 통째 복사(설계상 한 줄 include 로 붙게 돼 있음) | +| 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 랑 정반대).