مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
98 lines
3.8 KiB
Python
98 lines
3.8 KiB
Python
"""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))
|