Agent 2.0 exdev 서버 배포 스택

- server-dev start/stop/deploy 및 Gitea push 자동 배포
- local-dev 로컬 개발 환경

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-07-21 22:57:30 +09:00
commit 4b86b2a660
344 changed files with 45787 additions and 0 deletions
@@ -0,0 +1,455 @@
"""
api_clients.py
────────────────────────────────────────────
외부 API 클라이언트 모듈: LLM, TEI Embedding, vLLM Reranker
"""
import os
import time
import json
from typing import List, Dict, Any, Optional, Tuple
import httpx
import numpy as np
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
# ────────────────────────────────────────────
# 환경 변수 설정
# ────────────────────────────────────────────
# 신규 게이트웨이 방식: HTTPS + 호스트명 라우팅 + Bearer 토큰 + OpenAI 호환 포맷
# 예) LLM_BASE_URL=https://llm-ai.ex.co.kr (클라이언트가 /v1/chat/completions 등을 붙임)
LLM_BASE_URL = os.getenv("LLM_BASE_URL", os.getenv("SGLANG_BASE_URL", "http://localhost:16000"))
LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "default") # 모델 이름 (예: Qwen/Qwen3.6-27B-FP8)
TEI_EMBED_URL = os.getenv("TEI_EMBED_URL", "http://localhost:16001")
EMBED_MODEL_NAME = os.getenv("EMBED_MODEL_NAME", "Qwen/Qwen3-Embedding-8B") # OpenAI 임베딩 model 필드
TEI_RERANK_URL = os.getenv("TEI_RERANK_URL", "http://localhost:16002")
TEI_RERANK_MODEL = os.getenv("TEI_RERANK_MODEL", "Qwen/Qwen3-Reranker-8B") # 리랭커 model 필드
API_TIMEOUT = float(os.getenv("API_TIMEOUT", "60"))
# 공통 인증/TLS: 모든 모델 API가 동일 게이트웨이를 사용하므로 단일 키 사용 (LLM_API_KEY는 하위호환)
MODEL_API_KEY = os.getenv("MODEL_API_KEY", os.getenv("LLM_API_KEY", ""))
LLM_API_KEY = MODEL_API_KEY
# 자체서명 인증서면 검증 비활성화 (curl -k 와 동일). 기본 false
MODEL_VERIFY_SSL = os.getenv("MODEL_VERIFY_SSL", "false").strip().lower() in ("1", "true", "yes")
# openai: /v1/embeddings (게이트웨이) | tei: /embed (로컬 TEI·exdev 호스트 AI)
EMBED_API_STYLE = os.getenv("EMBED_API_STYLE", "openai").strip().lower()
# gateway: /score (queries+documents) | legacy: /rerank (query+documents, exdev reranker_server)
RERANK_API_STYLE = os.getenv("RERANK_API_STYLE", "gateway").strip().lower()
def _normalize_base_url(url: str) -> str:
"""URL 끝의 / 또는 /v1 접미사만 제거 (rstrip('/v1')는 포트 16001 등을 깨뜨림)."""
url = url.rstrip("/")
if url.endswith("/v1"):
return url[:-3]
return url
def _auth_headers() -> Dict[str, str]:
"""Bearer 인증 헤더 (키가 있을 때만)"""
return {"Authorization": f"Bearer {MODEL_API_KEY}"} if MODEL_API_KEY else {}
# ────────────────────────────────────────────
# LLM Client (OpenAI 호환 Chat Completions)
# ────────────────────────────────────────────
class SGLangClient:
"""
OpenAI 호환 LLM API 클라이언트
지원 서비스:
- SGLang (self-hosted)
- vLLM (self-hosted)
- Ollama (self-hosted)
- 내부 LLM (OpenAI 호환 형식)
- OpenAI API (API 키 필요)
"""
def __init__(
self,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
model_name: Optional[str] = None,
timeout: float = API_TIMEOUT
):
raw_url = base_url or LLM_BASE_URL
self.base_url = _normalize_base_url(raw_url)
self.model_name = model_name or LLM_MODEL_NAME
self.timeout = timeout
# API 키 설정 (있으면 헤더에 추가)
api_key = api_key or MODEL_API_KEY
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
if api_key:
print(f"[LLM Client] API 키 인증 활성화")
self.client = httpx.Client(timeout=timeout, headers=headers, verify=MODEL_VERIFY_SSL)
print(f"[LLM Client] 초기화: {self.base_url}, model={self.model_name}, "
f"timeout={timeout}s, verify_ssl={MODEL_VERIFY_SSL}")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
)
def chat_completion(
self,
messages: List[Dict[str, str]],
max_tokens: int = 64,
temperature: float = 0.1,
model: Optional[str] = None,
**kwargs
) -> str:
"""
Chat Completions API 호출 (OpenAI 호환 형식)
Args:
messages: [{"role": "system"|"user"|"assistant", "content": "..."}]
max_tokens: 최대 생성 토큰 수
temperature: 샘플링 온도 (0=deterministic)
model: 모델 이름 (지정하지 않으면 초기화 시 설정한 모델 사용)
Returns:
생성된 텍스트
"""
endpoint = f"{self.base_url}/v1/chat/completions"
payload = {
"model": model or self.model_name,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"chat_template_kwargs": {"enable_thinking": False},
**kwargs
}
try:
response = self.client.post(endpoint, json=payload)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
except httpx.HTTPStatusError as e:
print(f"[LLM Client] HTTP 오류: {e.response.status_code} - {e.response.text}")
raise
except Exception as e:
print(f"[LLM Client] 오류: {e}")
raise
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
)
def chat_completion_message(
self,
messages: List[Dict[str, Any]],
max_tokens: int = 512,
temperature: float = 0.1,
model: Optional[str] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = "auto",
**kwargs
) -> Dict[str, Any]:
"""Chat Completions → assistant message dict (content + tool_calls)."""
endpoint = f"{self.base_url}/v1/chat/completions"
payload: Dict[str, Any] = {
"model": model or self.model_name,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"chat_template_kwargs": {"enable_thinking": False},
**kwargs,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice
response = self.client.post(endpoint, json=payload)
try:
response.raise_for_status()
except httpx.HTTPStatusError as e:
print(f"[LLM Client] tool-call HTTP 오류: {e.response.status_code} - {e.response.text}")
raise
data = response.json()
return data["choices"][0]["message"]
def __del__(self):
if hasattr(self, 'client'):
self.client.close()
# ────────────────────────────────────────────
# TEI Embedding Client
# ────────────────────────────────────────────
class TEIEmbeddingClient:
"""Text Embeddings Inference (TEI) Embedding API 클라이언트"""
def __init__(self, base_url: str = TEI_EMBED_URL, model: str = EMBED_MODEL_NAME, timeout: float = API_TIMEOUT):
self.base_url = _normalize_base_url(base_url)
self.model = model
self.timeout = timeout
self.client = httpx.Client(timeout=timeout, headers=_auth_headers(), verify=MODEL_VERIFY_SSL)
self.api_style = EMBED_API_STYLE
print(f"[EmbeddingClient] 초기화: {self.base_url}, model={self.model}, "
f"style={self.api_style}, timeout={timeout}s, verify_ssl={MODEL_VERIFY_SSL}")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
)
def embed(self, texts: List[str], normalize: bool = True, is_query: bool = False) -> List[List[float]]:
"""
텍스트 임베딩 생성 (OpenAI 호환 /v1/embeddings, Qwen3-Embedding-8B)
Args:
texts: 임베딩할 텍스트 리스트
normalize: L2 정규화 여부 (코사인/Qdrant 호환). 서버 정규화에 의존하지 않고 클라이언트에서 수행
is_query: True면 검색 질의(Query), False면 문서(Document)
Returns:
임베딩 벡터 리스트 [[dim], [dim], ...]
Note:
Qwen3-Embedding은 Query/Document를 구분합니다:
- Query: "Instruct: ...\\nQuery: [질문]" 프리픽스
- Document: 원문 그대로
"""
# Qwen3-Embedding: Query에만 Instruct 문구 추가
if is_query:
processed_texts = [
f"Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: {text}"
for text in texts
]
else:
processed_texts = list(texts)
if self.api_style == "tei":
endpoint = f"{self.base_url}/embed"
payload = {"inputs": processed_texts, "normalize": normalize}
else:
endpoint = f"{self.base_url}/v1/embeddings"
payload = {"model": self.model, "input": processed_texts}
try:
response = self.client.post(endpoint, json=payload)
response.raise_for_status()
data = response.json()
if self.api_style == "tei":
vectors = data if isinstance(data, list) else data.get("embeddings", data)
elif isinstance(data, dict) and "data" in data:
items = sorted(data["data"], key=lambda x: x.get("index", 0))
vectors = [item["embedding"] for item in items]
elif isinstance(data, list):
vectors = data
elif isinstance(data, dict) and "embeddings" in data:
vectors = data["embeddings"]
else:
raise ValueError(f"예상치 못한 임베딩 응답 형식: {str(data)[:200]}")
if normalize:
vectors = self._l2_normalize(vectors)
return vectors
except httpx.HTTPStatusError as e:
print(f"[EmbeddingClient] HTTP 오류: {e.response.status_code} - {e.response.text}")
raise
except Exception as e:
print(f"[EmbeddingClient] 오류: {e}")
raise
@staticmethod
def _l2_normalize(vectors: List[List[float]]) -> List[List[float]]:
"""L2 정규화 (코사인 유사도/Qdrant 호환). 0벡터는 그대로 둠."""
arr = np.asarray(vectors, dtype="float32")
norms = np.linalg.norm(arr, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return (arr / norms).tolist()
def __del__(self):
if hasattr(self, 'client'):
self.client.close()
# ────────────────────────────────────────────
# vLLM Reranker Client
# ────────────────────────────────────────────
class TEIRerankerClient:
"""
vLLM 기반 Reranker API 클라이언트 (Qwen3-Reranker-8B)
Note: 클래스명은 호환성을 위해 'TEIRerankerClient'로 유지하지만,
실제로는 vLLM 기반 커스텀 reranker_server.py를 사용합니다.
"""
def __init__(self, base_url: str = TEI_RERANK_URL, model: str = TEI_RERANK_MODEL, timeout: float = API_TIMEOUT):
self.base_url = base_url.rstrip("/")
self.model = model
self.timeout = timeout
self.client = httpx.Client(timeout=timeout, headers=_auth_headers(), verify=MODEL_VERIFY_SSL)
self.api_style = RERANK_API_STYLE
print(f"[Reranker] 초기화: {self.base_url}, model={self.model}, "
f"style={self.api_style}, timeout={timeout}s, verify_ssl={MODEL_VERIFY_SSL}")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
)
def rerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
return_documents: bool = False
) -> List[Dict[str, Any]]:
"""
문서 재랭킹 (vLLM 기반 Qwen3-Reranker-8B)
Args:
query: 검색 질의
documents: 재랭킹할 문서 리스트
top_k: 상위 k개만 반환 (None이면 전체)
return_documents: 문서 텍스트도 함께 반환할지 여부
Returns:
[{"index": int, "score": float, "text": str (옵션)}, ...]
score가 높은 순서대로 정렬됨
Note:
vLLM 기반 커스텀 reranker_server.py 사용
- 내부적으로 "Yes/No" 확률을 계산
- 요청 형식: {"query": "질문", "documents": ["문서1", "문서2", ...]}
- 응답 형식: [{"index": int, "score": float}, ...] (score 내림차순 정렬)
"""
if self.api_style == "legacy":
endpoint = f"{self.base_url}/rerank"
payload = {"query": query, "documents": documents}
else:
endpoint = f"{self.base_url}/score"
payload = {
"model": self.model,
"queries": f"<Query>: {query}",
"documents": documents,
}
try:
print(f"[Reranker] 요청: endpoint={endpoint}, model={self.model}, query={query[:50]}..., documents={len(documents)}개")
response = self.client.post(endpoint, json=payload)
print(f"[Reranker] 응답 상태: {response.status_code}")
response.raise_for_status()
data = response.json()
results = self._parse_scores(data, len(documents))
print(f"[Reranker] 파싱 결과: {len(results)}개")
if results:
print(f"[Reranker] 첫 번째 결과: {results[0]}")
return results
except httpx.HTTPStatusError as e:
print(f"[Reranker] HTTP 오류: {e.response.status_code} - {e.response.text}")
raise
except Exception as e:
print(f"[Reranker] 오류: {e}")
raise
@staticmethod
def _parse_scores(data: Any, num_docs: int) -> List[Dict[str, Any]]:
"""
다양한 reranker 응답 형식을 [{"index": int, "score": float}, ...]로 정규화.
지원: {"results": [...]}, {"data": [...]}, {"scores": [...]}, bare list 등
항목은 {"index","score"} dict 또는 점수(float) 둘 다 허용.
"""
if isinstance(data, dict):
for key in ("results", "data", "scores", "output"):
if key in data:
data = data[key]
break
results: List[Dict[str, Any]] = []
if isinstance(data, list):
for i, item in enumerate(data):
if isinstance(item, dict):
idx = item.get("index", item.get("idx", i))
score = item.get("score", item.get("relevance_score", item.get("logit")))
if score is None:
continue
results.append({"index": int(idx), "score": float(score)})
elif isinstance(item, (int, float)):
results.append({"index": i, "score": float(item)})
if not results:
print(f"[Reranker] ⚠️ 점수 파싱 실패 (응답 형식 확인 필요): {str(data)[:200]}")
return results
def predict(self, pairs: List[Tuple[str, str]], batch_size: int = 16, show_progress_bar: bool = False) -> List[float]:
"""
CrossEncoder 호환 인터페이스
Args:
pairs: [(query, doc), (query, doc), ...] 쌍 리스트
batch_size: 배치 크기 (내부적으로 처리, 호환성 유지용)
show_progress_bar: 진행률 표시 (미사용, 호환성 유지용)
Returns:
점수 리스트 [score1, score2, ...]
"""
if not pairs:
return []
# 모든 쌍이 같은 query를 가진다고 가정 (일반적인 재랭킹 시나리오)
query = pairs[0][0]
documents = [doc for _, doc in pairs]
results = self.rerank(query, documents, return_documents=False)
# index 순서대로 정렬하여 점수 반환
sorted_results = sorted(results, key=lambda x: x["index"])
return [r["score"] for r in sorted_results]
def __del__(self):
if hasattr(self, 'client'):
self.client.close()
# ────────────────────────────────────────────
# 헬스체크 유틸리티
# ────────────────────────────────────────────
def check_api_health() -> Dict[str, bool]:
"""
모든 외부 API의 헬스체크 (게이트웨이 기준).
게이트웨이에는 /health가 없을 수 있으므로 '응답이 오면(상태코드 무관) 도달 가능'으로 판단한다.
"""
results = {}
headers = _auth_headers()
def _reachable(url: str) -> bool:
try:
# 어떤 HTTP 응답이든 오면 도달 가능으로 간주 (4xx 포함)
httpx.get(url, headers=headers, timeout=5, verify=MODEL_VERIFY_SSL)
return True
except Exception:
return False
llm_base = _normalize_base_url(LLM_BASE_URL)
results["llm"] = _reachable(f"{llm_base}/v1/models")
embed_base = TEI_EMBED_URL.rstrip("/")
results["tei_embed"] = _reachable(
f"{embed_base}/health" if EMBED_API_STYLE == "tei" else embed_base
)
results["tei_rerank"] = _reachable(TEI_RERANK_URL.rstrip("/"))
return results
if __name__ == "__main__":
# 간단한 테스트
print("=== API 클라이언트 테스트 ===")
print(f"LLM API: {LLM_BASE_URL}")
print(f"LLM Model: {LLM_MODEL_NAME}")
print(f"TEI Embed: {TEI_EMBED_URL}")
print(f"TEI Rerank: {TEI_RERANK_URL}")
print()
health = check_api_health()
print("헬스체크 결과:")
for service, status in health.items():
print(f" {service}: {'✅ OK' if status else '❌ FAIL'}")