"""Real model adapter for any OpenAI-compatible endpoint. Works with OpenAI, OpenRouter, vLLM, LiteLLM, Ollama's OpenAI shim, etc. The API key is read from the credential record (decrypted by the caller), never from the environment at request time. """ from __future__ import annotations import time import httpx from app.application.ports import ChatMessage, ModelHealth, ModelResponse from app.core.errors import ProviderUnavailableError from app.domain.ai_control.models import AiModel, ModelCredential class OpenAICompatibleModelAdapter: def __init__(self, *, base_url: str, default_api_key: str = "", timeout: float = 120.0) -> None: self.base_url = (base_url or "").rstrip("/") self.default_api_key = default_api_key self.timeout = timeout def _resolve_key(self, credential: ModelCredential | None, decrypt) -> str: if credential is not None and credential.encrypted_api_key: try: return decrypt(credential.encrypted_api_key) except Exception: # noqa: BLE001 - fall through to the env default pass return self.default_api_key async def complete( self, *, model: AiModel, credential: ModelCredential | None, messages: list[ChatMessage], system: str = "", temperature: float = 0.2, max_tokens: int = 2048, ) -> ModelResponse: if not self.base_url: raise ProviderUnavailableError( "OPENAI_COMPATIBLE_BASE_URL تنظیم نشده است." ) payload_messages = ([{"role": "system", "content": system}] if system else []) + [ {"role": m.role, "content": m.content} for m in messages ] payload = { "model": model.internal_name, "messages": payload_messages, "temperature": temperature, "max_tokens": max_tokens, } headers = {"Content-Type": "application/json"} api_key = self.default_api_key if api_key: headers["Authorization"] = f"Bearer {api_key}" started = time.perf_counter() try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.post( f"{self.base_url}/chat/completions", json=payload, headers=headers ) response.raise_for_status() data = response.json() except httpx.HTTPStatusError as exc: raise ProviderUnavailableError( f"ارائه‌دهندهٔ مدل خطا برگرداند ({exc.response.status_code})." ) from exc except httpx.HTTPError as exc: raise ProviderUnavailableError("اتصال به ارائه‌دهندهٔ مدل برقرار نشد.") from exc choice = (data.get("choices") or [{}])[0] content = (choice.get("message") or {}).get("content", "") or "" usage = data.get("usage") or {} return ModelResponse( content=content, model_internal_name=data.get("model", model.internal_name), input_tokens=int(usage.get("prompt_tokens", 0) or 0), output_tokens=int(usage.get("completion_tokens", 0) or 0), latency_ms=int((time.perf_counter() - started) * 1000), raw={"id": data.get("id", "")}, ) async def health(self) -> ModelHealth: if not self.base_url: return ModelHealth(ok=False, detail="base_url تنظیم نشده است.") try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(f"{self.base_url}/models") return ModelHealth(ok=response.status_code < 500, detail=str(response.status_code)) except httpx.HTTPError as exc: return ModelHealth(ok=False, detail=str(exc))