confident-ai--deepeval
361 行
12 KiB
Python
361 行
12 KiB
Python
import logging
|
|
import math
|
|
from typing import Optional, Any, NamedTuple, Union, Tuple
|
|
import aiohttp
|
|
import requests
|
|
from enum import Enum
|
|
import os
|
|
from tenacity import (
|
|
retry,
|
|
wait_exponential_jitter,
|
|
retry_if_exception_type,
|
|
RetryCallState,
|
|
)
|
|
from pydantic import SecretStr
|
|
|
|
import deepeval
|
|
from deepeval.key_handler import KEY_FILE_HANDLER, KeyValues
|
|
from deepeval.confident.types import ApiResponse, ConfidentApiError
|
|
from deepeval.config.settings import get_settings
|
|
|
|
CONFIDENT_API_KEY_ENV_VAR = "CONFIDENT_API_KEY"
|
|
DEEPEVAL_BASE_URL = "https://deepeval.confident-ai.com"
|
|
DEEPEVAL_BASE_URL_EU = "https://eu.deepeval.confident-ai.com"
|
|
DEEPEVAL_BASE_URL_AU = "https://au.deepeval.confident-ai.com"
|
|
API_BASE_URL = "https://api.confident-ai.com"
|
|
API_BASE_URL_EU = "https://eu.api.confident-ai.com"
|
|
API_BASE_URL_AU = "https://au.api.confident-ai.com"
|
|
retryable_exceptions = requests.exceptions.SSLError
|
|
|
|
|
|
def _infer_region_from_api_key(api_key: Optional[str]) -> Optional[str]:
|
|
"""
|
|
Infer region from Confident API key prefix.
|
|
|
|
Supported:
|
|
- confident_eu_... => "EU"
|
|
- confident_us_... => "US"
|
|
- confident_au_... => "AU"
|
|
|
|
Returns None if prefix is not recognized or api_key is falsy.
|
|
"""
|
|
if not api_key:
|
|
return None
|
|
key = api_key.strip().lower()
|
|
if key.startswith("confident_eu_"):
|
|
return "EU"
|
|
if key.startswith("confident_us_"):
|
|
return "US"
|
|
if key.startswith("confident_au_"):
|
|
return "AU"
|
|
return None
|
|
|
|
|
|
class BackendResolution(NamedTuple):
|
|
base_url: str
|
|
region: Optional[str]
|
|
# "custom_base_url" | "explicit_region" | "api_key_prefix" | "default"
|
|
source: str
|
|
|
|
|
|
def _api_url_for_region(region: Optional[str]) -> str:
|
|
if region == "EU":
|
|
return API_BASE_URL_EU
|
|
if region == "AU":
|
|
return API_BASE_URL_AU
|
|
return API_BASE_URL
|
|
|
|
|
|
def resolve_backend() -> BackendResolution:
|
|
"""Resolve the Confident AI API base URL, along with the region and how
|
|
it was decided.
|
|
|
|
Single source of truth for backend routing: `get_base_api_url()` (used
|
|
by all API calls) and `deepeval diagnose` (which displays the region and
|
|
its provenance) both go through here, so the two cannot drift.
|
|
"""
|
|
s = get_settings()
|
|
if s.CONFIDENT_BASE_URL:
|
|
return BackendResolution(
|
|
s.CONFIDENT_BASE_URL.rstrip("/"), None, "custom_base_url"
|
|
)
|
|
|
|
# If the user has explicitly set a region, respect it.
|
|
region = KEY_FILE_HANDLER.fetch_data(KeyValues.CONFIDENT_REGION)
|
|
if region:
|
|
return BackendResolution(
|
|
_api_url_for_region(region), region, "explicit_region"
|
|
)
|
|
|
|
# Otherwise, infer region from the API key prefix.
|
|
inferred = _infer_region_from_api_key(get_confident_api_key())
|
|
if inferred:
|
|
return BackendResolution(
|
|
_api_url_for_region(inferred), inferred, "api_key_prefix"
|
|
)
|
|
|
|
# Default to US (backwards compatible)
|
|
return BackendResolution(API_BASE_URL, "US", "default")
|
|
|
|
|
|
def get_base_api_url():
|
|
return resolve_backend().base_url
|
|
|
|
|
|
def get_confident_api_key() -> Optional[str]:
|
|
s = get_settings()
|
|
key: Optional[SecretStr] = s.CONFIDENT_API_KEY
|
|
return key.get_secret_value() if key else None
|
|
|
|
|
|
def set_confident_api_key(api_key: Optional[str]) -> None:
|
|
"""
|
|
- Always updates runtime (os.environ) via settings.edit()
|
|
- If DEEPEVAL_DEFAULT_SAVE is set, also persists to dotenv
|
|
- Never writes secrets to the legacy JSON keystore (your Settings logic already skips secrets)
|
|
"""
|
|
s = get_settings()
|
|
save = (
|
|
s.DEEPEVAL_DEFAULT_SAVE or None
|
|
) # e.g. "dotenv" or "dotenv:/path/.env"
|
|
|
|
# If you *only* want runtime changes unless a default save is present:
|
|
if save is None:
|
|
with s.edit(persist=False):
|
|
s.CONFIDENT_API_KEY = SecretStr(api_key) if api_key else None
|
|
else:
|
|
# Respect default save: update runtime + write to dotenv, but not JSON
|
|
with s.edit(save=save, persist=None):
|
|
s.CONFIDENT_API_KEY = SecretStr(api_key) if api_key else None
|
|
|
|
|
|
def is_confident():
|
|
return get_confident_api_key() is not None
|
|
|
|
|
|
def log_retry_error(retry_state: RetryCallState):
|
|
exception = retry_state.outcome.exception()
|
|
logging.error(
|
|
f"Confident AI Error: {exception}. Retrying: {retry_state.attempt_number} time(s)..."
|
|
)
|
|
|
|
|
|
class HttpMethods(Enum):
|
|
GET = "GET"
|
|
POST = "POST"
|
|
DELETE = "DELETE"
|
|
PUT = "PUT"
|
|
|
|
|
|
class Endpoints(Enum):
|
|
DATASET_ALIAS_ENDPOINT = "/v1/datasets/:alias"
|
|
DATASET_ALIAS_QUEUE_ENDPOINT = "/v1/datasets/:alias/queue"
|
|
DATASET_ALIAS_VERSIONS_ENDPOINT = "/v1/datasets/:alias/versions"
|
|
|
|
TEST_RUN_ENDPOINT = "/v1/test-run"
|
|
EXPERIMENT_ENDPOINT = "/v1/experiment"
|
|
TRACES_ENDPOINT = "/v1/traces"
|
|
ANNOTATIONS_ENDPOINT = "/v1/annotations"
|
|
PROMPTS_VERSION_ID_ENDPOINT = "/v1/prompts/:alias/versions/:version"
|
|
PROMPTS_LABEL_ENDPOINT = "/v1/prompts/:alias/labels/:label"
|
|
PROMPTS_ENDPOINT = "/v1/prompts"
|
|
PROMPTS_VERSIONS_ENDPOINT = "/v1/prompts/:alias/versions"
|
|
PROMPTS_COMMITS_ENDPOINT = "/v1/prompts/:alias/commits"
|
|
PROMPTS_COMMIT_HASH_ENDPOINT = "/v1/prompts/:alias/commits/:hash"
|
|
PROMPTS_BRANCHES_ENDPOINT = "/v1/prompts/:alias/branches"
|
|
PROMPTS_BRANCH_ENDPOINT = "/v1/prompts/:alias/branches/:name"
|
|
EVALUATE_ENDPOINT = "/v1/evaluate"
|
|
|
|
EVALUATE_THREAD_ENDPOINT = "/v1/evaluate/threads/:threadId"
|
|
EVALUATE_TRACE_ENDPOINT = "/v1/evaluate/traces/:traceUuid"
|
|
EVALUATE_SPAN_ENDPOINT = "/v1/evaluate/spans/:spanUuid"
|
|
|
|
METRICS_ENDPOINT = "/v1/metrics"
|
|
METRIC_ENDPOINT = "/v1/metric/:name"
|
|
|
|
GOVERNANCE_ASSESS_ENDPOINT = "/v1/governance/assess"
|
|
|
|
|
|
def _sanitize_body(obj):
|
|
"""Recursively replace non-finite floats (NaN, Inf, -Inf) with None.
|
|
|
|
Python's json.dumps() happily serializes float('nan') as the
|
|
literal token ``NaN`` which is **not** valid JSON and causes
|
|
server-side parsing failures. This helper walks any dict/list
|
|
structure and neutralises those values before the payload is
|
|
handed to the HTTP layer.
|
|
"""
|
|
if isinstance(obj, float):
|
|
return None if not math.isfinite(obj) else obj
|
|
if isinstance(obj, dict):
|
|
return {k: _sanitize_body(v) for k, v in obj.items()}
|
|
if isinstance(obj, (list, tuple)):
|
|
return [_sanitize_body(v) for v in obj]
|
|
return obj
|
|
|
|
|
|
class Api:
|
|
def __init__(
|
|
self,
|
|
api_key: Optional[str] = None,
|
|
verify_ssl: Optional[bool] = None,
|
|
):
|
|
if api_key is None:
|
|
api_key = get_confident_api_key()
|
|
|
|
if not api_key:
|
|
raise ValueError(
|
|
f"No Confident API key found. Please run `deepeval login` or set the {CONFIDENT_API_KEY_ENV_VAR} environment variable in the CLI."
|
|
)
|
|
|
|
self.api_key = api_key
|
|
self._headers = {
|
|
"Content-Type": "application/json",
|
|
"CONFIDENT-API-KEY": api_key,
|
|
"X-DeepEval-Version": deepeval.__version__,
|
|
}
|
|
self.base_api_url = get_base_api_url()
|
|
|
|
# Resolve SSL verification. Explicit constructor argument wins;
|
|
# otherwise fall back to the CONFIDENT_DISABLE_SSL setting.
|
|
if verify_ssl is None:
|
|
verify_ssl = not bool(get_settings().CONFIDENT_DISABLE_SSL)
|
|
self.verify_ssl = verify_ssl
|
|
|
|
@retry(
|
|
wait=wait_exponential_jitter(initial=1, exp_base=2, jitter=2, max=10),
|
|
retry=retry_if_exception_type(retryable_exceptions),
|
|
after=log_retry_error,
|
|
)
|
|
def _http_request(
|
|
self, method: str, url: str, headers=None, json=None, params=None
|
|
):
|
|
session = requests.Session()
|
|
return session.request(
|
|
method=method,
|
|
url=url,
|
|
headers=headers,
|
|
json=json,
|
|
params=params,
|
|
verify=self.verify_ssl,
|
|
)
|
|
|
|
def _handle_response(
|
|
self, response_data: Union[dict, Any]
|
|
) -> Tuple[Any, Optional[str]]:
|
|
if not isinstance(response_data, dict):
|
|
return response_data, None
|
|
|
|
try:
|
|
api_response = ApiResponse(**response_data)
|
|
except Exception:
|
|
return response_data, None
|
|
|
|
if api_response.deprecated:
|
|
deprecation_msg = "You are using a deprecated API endpoint. Please update your deepeval version."
|
|
if api_response.link:
|
|
deprecation_msg += f" See: {api_response.link}"
|
|
logging.warning(deprecation_msg)
|
|
|
|
if not api_response.success:
|
|
error_message = api_response.error or "Request failed"
|
|
raise ConfidentApiError(error_message, api_response.link)
|
|
|
|
return api_response.data, api_response.link
|
|
|
|
def send_request(
|
|
self,
|
|
method: HttpMethods,
|
|
endpoint: Endpoints,
|
|
body=None,
|
|
params=None,
|
|
url_params=None,
|
|
) -> Tuple[Any, Optional[str]]:
|
|
url = f"{self.base_api_url}{endpoint.value}"
|
|
|
|
# Replace URL parameters if provided
|
|
if url_params:
|
|
for key, value in url_params.items():
|
|
placeholder = f":{key}"
|
|
if placeholder in url:
|
|
url = url.replace(placeholder, str(value))
|
|
|
|
if body is not None:
|
|
body = _sanitize_body(body)
|
|
|
|
res = self._http_request(
|
|
method=method.value,
|
|
url=url,
|
|
headers=self._headers,
|
|
json=body,
|
|
params=params,
|
|
)
|
|
|
|
if res.status_code == 200:
|
|
try:
|
|
response_data = res.json()
|
|
return self._handle_response(response_data)
|
|
except ValueError:
|
|
return res.text, None
|
|
else:
|
|
try:
|
|
error_data = res.json()
|
|
return self._handle_response(error_data)
|
|
except (ValueError, ConfidentApiError) as e:
|
|
if isinstance(e, ConfidentApiError):
|
|
raise e
|
|
error_message = (
|
|
error_data.get("error", res.text)
|
|
if "error_data" in locals()
|
|
else res.text
|
|
)
|
|
raise Exception(error_message)
|
|
|
|
async def a_send_request(
|
|
self,
|
|
method: HttpMethods,
|
|
endpoint: Endpoints,
|
|
body=None,
|
|
params=None,
|
|
url_params=None,
|
|
) -> Tuple[Any, Optional[str]]:
|
|
url = f"{self.base_api_url}{endpoint.value}"
|
|
|
|
if url_params:
|
|
for key, value in url_params.items():
|
|
placeholder = f":{key}"
|
|
if placeholder in url:
|
|
url = url.replace(placeholder, str(value))
|
|
|
|
if body is not None:
|
|
body = _sanitize_body(body)
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.request(
|
|
method=method.value,
|
|
url=url,
|
|
headers=self._headers,
|
|
json=body,
|
|
params=params,
|
|
ssl=self.verify_ssl,
|
|
) as res:
|
|
if res.status == 200:
|
|
try:
|
|
response_data = await res.json()
|
|
return self._handle_response(response_data)
|
|
except aiohttp.ContentTypeError:
|
|
return await res.text(), None
|
|
else:
|
|
try:
|
|
error_data = await res.json()
|
|
return self._handle_response(error_data)
|
|
except (aiohttp.ContentTypeError, ConfidentApiError) as e:
|
|
if isinstance(e, ConfidentApiError):
|
|
raise e
|
|
error_message = (
|
|
error_data.get("error", await res.text())
|
|
if "error_data" in locals()
|
|
else await res.text()
|
|
)
|
|
raise Exception(error_message)
|