Files
RAG/backend/app/adapters/bailian/_base.py
YoVinchen f4ba5d5342
Some checks failed
verify / verify (push) Has been cancelled
Make the first RAG slice executable without risking production data
The Stage 1 foundation now proves provider contracts with mocks and validates PostgreSQL/pgvector ingestion, approval binding, retrieval, reranking, and idempotency using only synthetic data. Live Bailian validation remains gated on rotating the exposed key.

Constraint: The key shown in chat is compromised and cannot be used or committed

Rejected: Mark Stage 1 complete from mock and offline results | real three-model smoke is still required

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Do not enable real-data ingestion until Stage 3 cloud approval and outbound manifest controls are enforced end to end

Tested: make verify; 41 pytest tests; strict mypy; Ruff; Compose config; pinned image build; empty-volume migration; role denial; two idempotent 20-vector seeds; database restart persistence

Not-tested: Live Bailian calls require a newly rotated key; React product UI is not implemented
2026-07-12 15:41:58 +08:00

383 lines
12 KiB
Python

"""Shared HTTP and validation machinery for Alibaba Cloud Model Studio."""
from __future__ import annotations
import asyncio
import re
import secrets
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
from time import perf_counter
from typing import Any, Self
from urllib.parse import urlsplit
import httpx
from app.ports.model_providers import (
ModelProviderError,
ProviderErrorKind,
ProviderUsage,
TokenCounter,
)
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,127}$")
_BAILIAN_BEIJING_HOST = re.compile(
r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.cn-beijing\.maas\.aliyuncs\.com$"
)
def conservative_utf8_token_count(text: str) -> int:
"""Return a dependency-free conservative upper bound for byte-BPE tokens.
Production may inject the provider tokenizer through ``token_counter``. A
UTF-8 byte count is deliberately conservative and, unlike a word count,
does not undercount Chinese text or byte-fallback tokens.
"""
return len(text.encode("utf-8"))
def count_tokens(
counter: TokenCounter,
text: str,
*,
operation: str,
) -> int:
try:
count = counter(text)
except Exception:
raise sanitized_error(
operation=operation,
kind=ProviderErrorKind.INVALID_REQUEST,
provider_code="token_count_failed",
) from None
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
raise sanitized_error(
operation=operation,
kind=ProviderErrorKind.INVALID_REQUEST,
provider_code="invalid_token_count",
)
return count
def sanitized_error(
*,
operation: str,
kind: ProviderErrorKind,
status_code: int | None = None,
provider_code: str | None = None,
request_id: str | None = None,
retryable: bool = False,
) -> ModelProviderError:
return ModelProviderError(
operation=operation,
kind=kind,
status_code=status_code,
provider_code=provider_code,
request_id=request_id,
retryable=retryable,
)
def invalid_request(operation: str, code: str) -> ModelProviderError:
return sanitized_error(
operation=operation,
kind=ProviderErrorKind.INVALID_REQUEST,
provider_code=code,
)
def invalid_response(operation: str, code: str) -> ModelProviderError:
return sanitized_error(
operation=operation,
kind=ProviderErrorKind.INVALID_RESPONSE,
provider_code=code,
)
def parse_usage(value: Any) -> ProviderUsage:
if not isinstance(value, Mapping):
return ProviderUsage()
return ProviderUsage(
input_tokens=_first_nonnegative_int(
value.get("prompt_tokens"),
value.get("input_tokens"),
),
output_tokens=_first_nonnegative_int(
value.get("completion_tokens"),
value.get("output_tokens"),
),
total_tokens=_nonnegative_int(value.get("total_tokens")),
)
def response_model(
body: Mapping[str, Any],
requested_model: str,
*,
sensitive_values: Sequence[str] = (),
) -> str:
return safe_identifier(body.get("model"), sensitive_values=sensitive_values) or requested_model
def safe_identifier(
value: Any,
*,
sensitive_values: Sequence[str] = (),
) -> str | None:
if not isinstance(value, str) or not _SAFE_IDENTIFIER.fullmatch(value):
return None
if any(value in sensitive or sensitive in value for sensitive in sensitive_values if sensitive):
return None
return value
def extract_request_id(
body: Mapping[str, Any],
*,
sensitive_values: Sequence[str] = (),
) -> str | None:
return safe_identifier(
body.get("id") or body.get("request_id"),
sensitive_values=sensitive_values,
)
def _nonnegative_int(value: Any) -> int | None:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return None
return int(value)
def _first_nonnegative_int(*values: Any) -> int | None:
for value in values:
parsed = _nonnegative_int(value)
if parsed is not None:
return parsed
return None
@dataclass(frozen=True, slots=True)
class JsonResponse:
body: Mapping[str, Any]
elapsed_ms: float
class BailianHttpAdapter:
"""Minimal async HTTP client with sanitized error translation."""
def __init__(
self,
*,
api_key: str,
base_url: str,
expected_path: str,
http_client: httpx.AsyncClient | None,
timeout_seconds: float,
max_retries: int = 0,
retry_base_seconds: float = 0.5,
) -> None:
if not api_key or api_key != api_key.strip():
raise invalid_request("configuration", "invalid_api_key_value")
if timeout_seconds <= 0:
raise invalid_request("configuration", "invalid_timeout")
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
raise invalid_request("configuration", "invalid_max_retries")
if retry_base_seconds < 0:
raise invalid_request("configuration", "invalid_retry_base")
self._base_url = _validated_base_url(base_url, expected_path)
self._api_key = api_key
self._owns_client = http_client is None
self._client = http_client or httpx.AsyncClient(
timeout=httpx.Timeout(timeout_seconds),
follow_redirects=False,
)
self._max_retries = max_retries
self._retry_base_seconds = retry_base_seconds
async def aclose(self) -> None:
if self._owns_client:
await self._client.aclose()
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *_: object) -> None:
await self.aclose()
def _url(self, path: str) -> str:
return f"{self._base_url}/{path.lstrip('/')}"
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
async def _post_json(
self,
*,
operation: str,
path: str,
payload: Mapping[str, Any],
sensitive_values: Sequence[str],
) -> JsonResponse:
started = perf_counter()
for attempt in range(self._max_retries + 1):
try:
response = await self._client.post(
self._url(path),
headers=self._headers(),
json=payload,
)
except httpx.TimeoutException:
error = sanitized_error(
operation=operation,
kind=ProviderErrorKind.TIMEOUT,
provider_code="request_timeout",
retryable=True,
)
if await self._maybe_retry(error, attempt=attempt, response=None):
continue
raise error from None
except httpx.HTTPError:
error = sanitized_error(
operation=operation,
kind=ProviderErrorKind.TRANSPORT,
provider_code="transport_error",
retryable=True,
)
if await self._maybe_retry(error, attempt=attempt, response=None):
continue
raise error from None
if response.status_code >= 400:
try:
self._raise_http_error(
operation=operation,
response=response,
sensitive_values=(*sensitive_values, self._api_key),
)
except ModelProviderError as error:
if await self._maybe_retry(error, attempt=attempt, response=response):
continue
raise
try:
body = response.json()
except ValueError:
raise invalid_response(operation, "invalid_json") from None
if not isinstance(body, Mapping):
raise invalid_response(operation, "invalid_json_object")
return JsonResponse(body=body, elapsed_ms=(perf_counter() - started) * 1000)
raise AssertionError("bounded provider retry loop exhausted unexpectedly")
async def _maybe_retry(
self,
error: ModelProviderError,
*,
attempt: int,
response: httpx.Response | None,
) -> bool:
if not error.retryable or attempt >= self._max_retries:
return False
await asyncio.sleep(self._retry_delay(attempt=attempt, response=response))
return True
def _retry_delay(self, *, attempt: int, response: httpx.Response | None) -> float:
if response is not None:
retry_after = response.headers.get("Retry-After")
if retry_after:
parsed_delay = _parse_retry_after(retry_after)
if parsed_delay is not None:
return min(max(parsed_delay, 0.0), 30.0)
base_delay = min(self._retry_base_seconds * (2**attempt), 30.0)
if base_delay == 0:
return 0.0
jitter = base_delay * (float(secrets.randbelow(251)) / 1000.0)
return float(min(base_delay + jitter, 30.0))
def _raise_http_error(
self,
*,
operation: str,
response: httpx.Response,
sensitive_values: Sequence[str],
) -> None:
body: Mapping[str, Any] = {}
try:
decoded = response.json()
if isinstance(decoded, Mapping):
body = decoded
except (ValueError, httpx.ResponseNotRead):
pass
nested_error = body.get("error")
error_body = nested_error if isinstance(nested_error, Mapping) else body
code = safe_identifier(
error_body.get("code"),
sensitive_values=sensitive_values,
)
request_id = extract_request_id(body, sensitive_values=sensitive_values)
kind, retryable = _kind_for_status(response.status_code)
raise sanitized_error(
operation=operation,
kind=kind,
status_code=response.status_code,
provider_code=code,
request_id=request_id,
retryable=retryable,
) from None
def _validated_base_url(base_url: str, expected_path: str) -> str:
if not base_url or base_url != base_url.strip():
raise invalid_request("configuration", "invalid_base_url")
parsed = urlsplit(base_url)
normalized_path = parsed.path.rstrip("/")
if (
parsed.scheme != "https"
or not parsed.hostname
or not _BAILIAN_BEIJING_HOST.fullmatch(parsed.hostname)
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or normalized_path != expected_path
):
raise invalid_request("configuration", "invalid_base_url")
return base_url.rstrip("/")
def _kind_for_status(status_code: int) -> tuple[ProviderErrorKind, bool]:
if status_code == 400:
return ProviderErrorKind.INVALID_REQUEST, False
if status_code == 401:
return ProviderErrorKind.AUTHENTICATION, False
if status_code == 403:
return ProviderErrorKind.PERMISSION_DENIED, False
if status_code == 404:
return ProviderErrorKind.NOT_FOUND, False
if status_code == 408:
return ProviderErrorKind.TIMEOUT, True
if status_code == 429:
return ProviderErrorKind.RATE_LIMITED, True
return ProviderErrorKind.UPSTREAM, status_code >= 500
def _parse_retry_after(value: str) -> float | None:
try:
return float(value)
except ValueError:
try:
retry_at = parsedate_to_datetime(value)
except (TypeError, ValueError, OverflowError):
return None
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=UTC)
return (retry_at - datetime.now(UTC)).total_seconds()