Some checks failed
verify / verify (push) Has been cancelled
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
383 lines
12 KiB
Python
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()
|