نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user