"""/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_vision_model_only_when_image_attached(): cfg = FabrixConfig.from_env({**FULL_ENV, "AAF_FABRIX_VISION_MODEL_ID": "605"}) text = {"model": "339", "messages": [{"role": "user", "content": "hi"}]} img = {"model": "339", "messages": [{"role": "user", "content": [ {"type": "text", "text": "what is this"}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, ]}]} assert cfg.prepare(text)[1]["x-llm-model-id"] == "339" assert cfg.prepare(img)[1]["x-llm-model-id"] == "605" # 설정 없으면 분기 안 함 assert FabrixConfig.from_env(FULL_ENV).prepare(img)[1]["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, LANGFUSE={"host": "http://lf.test", "public_key": "pk", "secret_key": "sk"}) async def test_stream_reports_generation_to_langfuse(upstream, monkeypatch): import asyncio from apps.gateway import langfuse got = [] monkeypatch.setattr(langfuse, "TRANSPORT", httpx.MockTransport(lambda r: (got.append(json.loads(r.content)), httpx.Response(207, json={}))[1])) upstream(lambda r: _sse(b'data: {"choices":[{"delta":{"content":"hi"}}],"usage":{"prompt_tokens":2,"completion_tokens":1}}\n\ndata: [DONE]\n\n')) resp = await _post({"model": "581", "stream": True, "messages": [{"role": "user", "content": "q"}]}) b"".join([c async for c in resp.streaming_content]) await asyncio.gather(*list(langfuse._pending)) gen = got[0]["batch"][1]["body"] assert gen["model"] == "581" and gen["output"] == "hi" and gen["usage"]["total"] == 3 and gen["input"][0]["content"] == "q" @override_settings(FABRIX_ENV=FULL_ENV) async def test_flaky_401_on_right_auth_is_retried_once(upstream, monkeypatch): """FabriX 가 맞는 조합에도 가끔 401 — 4개 다 돈 뒤 맨 앞 조합 한 번 더.""" from apps.gateway import views monkeypatch.setattr(views, "RETRY_PAUSE_S", 0) hits = {"n": 0} def handler(req: httpx.Request) -> httpx.Response: hits["n"] += 1 if hits["n"] <= 4: return httpx.Response(401, content=b"flaky") return httpx.Response(200, json={"choices": []}) calls = upstream(handler) resp = await _post({"messages": []}) assert resp.status_code == 200 and len(calls) == 5 assert calls[0].headers.get("x-openapi-token") == calls[4].headers.get("x-openapi-token") @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"]