Make the first RAG slice executable without risking production data
Some checks failed
verify / verify (push) Has been cancelled
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
This commit is contained in:
@@ -1 +0,0 @@
|
||||
|
||||
11
backend/app/adapters/bailian/__init__.py
Normal file
11
backend/app/adapters/bailian/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Alibaba Cloud Model Studio provider adapters."""
|
||||
|
||||
from app.adapters.bailian.chat import BailianChatAdapter
|
||||
from app.adapters.bailian.embedding import BailianEmbeddingAdapter
|
||||
from app.adapters.bailian.rerank import BailianRerankerAdapter
|
||||
|
||||
__all__ = [
|
||||
"BailianChatAdapter",
|
||||
"BailianEmbeddingAdapter",
|
||||
"BailianRerankerAdapter",
|
||||
]
|
||||
382
backend/app/adapters/bailian/_base.py
Normal file
382
backend/app/adapters/bailian/_base.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""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()
|
||||
326
backend/app/adapters/bailian/chat.py
Normal file
326
backend/app/adapters/bailian/chat.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""Alibaba Cloud Model Studio OpenAI-compatible chat adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.bailian._base import (
|
||||
BailianHttpAdapter,
|
||||
extract_request_id,
|
||||
invalid_request,
|
||||
invalid_response,
|
||||
parse_usage,
|
||||
response_model,
|
||||
sanitized_error,
|
||||
)
|
||||
from app.ports.model_providers import (
|
||||
ChatCompletionResult,
|
||||
ChatMessage,
|
||||
ChatStreamEvent,
|
||||
ModelProviderError,
|
||||
ProviderErrorKind,
|
||||
ProviderUsage,
|
||||
)
|
||||
|
||||
_ALLOWED_ROLES = frozenset({"system", "user", "assistant"})
|
||||
|
||||
|
||||
class BailianChatAdapter(BailianHttpAdapter):
|
||||
"""Call chat completions with thinking and web search forcibly disabled."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str = "deepseek-v4-flash",
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
timeout_seconds: float = 60.0,
|
||||
max_retries: int = 0,
|
||||
retry_base_seconds: float = 0.5,
|
||||
) -> None:
|
||||
if not model or model != model.strip():
|
||||
raise invalid_request("chat.configuration", "invalid_model")
|
||||
super().__init__(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
expected_path="/compatible-mode/v1",
|
||||
http_client=http_client,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_retries=max_retries,
|
||||
retry_base_seconds=retry_base_seconds,
|
||||
)
|
||||
self._model = model
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
messages: Sequence[ChatMessage],
|
||||
*,
|
||||
max_tokens: int,
|
||||
) -> ChatCompletionResult:
|
||||
operation = "chat.complete"
|
||||
validated_messages = self._validate_messages(
|
||||
messages,
|
||||
max_tokens=max_tokens,
|
||||
operation=operation,
|
||||
)
|
||||
payload = self._payload(validated_messages, max_tokens=max_tokens, stream=False)
|
||||
sensitive_values = tuple(message.content for message in validated_messages)
|
||||
response = await self._post_json(
|
||||
operation=operation,
|
||||
path="chat/completions",
|
||||
payload=payload,
|
||||
sensitive_values=sensitive_values,
|
||||
)
|
||||
content, finish_reason = self._parse_completion(
|
||||
response.body,
|
||||
operation=operation,
|
||||
)
|
||||
return ChatCompletionResult(
|
||||
content=content,
|
||||
finish_reason=finish_reason,
|
||||
model=response_model(
|
||||
response.body,
|
||||
self._model,
|
||||
sensitive_values=(*sensitive_values, self._api_key),
|
||||
),
|
||||
request_id=extract_request_id(
|
||||
response.body,
|
||||
sensitive_values=(*sensitive_values, self._api_key),
|
||||
),
|
||||
usage=parse_usage(response.body.get("usage")),
|
||||
elapsed_ms=response.elapsed_ms,
|
||||
)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: Sequence[ChatMessage],
|
||||
*,
|
||||
max_tokens: int,
|
||||
) -> AsyncIterator[ChatStreamEvent]:
|
||||
operation = "chat.stream"
|
||||
validated_messages = self._validate_messages(
|
||||
messages,
|
||||
max_tokens=max_tokens,
|
||||
operation=operation,
|
||||
)
|
||||
payload = self._payload(validated_messages, max_tokens=max_tokens, stream=True)
|
||||
sensitive_values = tuple(message.content for message in validated_messages)
|
||||
started = perf_counter()
|
||||
for attempt in range(self._max_retries + 1):
|
||||
emitted = False
|
||||
try:
|
||||
async with self._client.stream(
|
||||
"POST",
|
||||
self._url("chat/completions"),
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
await response.aread()
|
||||
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
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line or line.startswith(":"):
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
raise invalid_response(operation, "invalid_sse_event")
|
||||
raw_data = line[5:].strip()
|
||||
if raw_data == "[DONE]":
|
||||
return
|
||||
event_body = self._decode_stream_event(raw_data, operation=operation)
|
||||
event = self._parse_stream_event(
|
||||
event_body,
|
||||
operation=operation,
|
||||
sensitive_values=sensitive_values,
|
||||
elapsed_ms=(perf_counter() - started) * 1000,
|
||||
)
|
||||
emitted = True
|
||||
yield event
|
||||
return
|
||||
except ModelProviderError as error:
|
||||
if not emitted and await self._maybe_retry(
|
||||
error,
|
||||
attempt=attempt,
|
||||
response=None,
|
||||
):
|
||||
continue
|
||||
raise
|
||||
except httpx.TimeoutException:
|
||||
timeout_error = sanitized_error(
|
||||
operation=operation,
|
||||
kind=ProviderErrorKind.TIMEOUT,
|
||||
provider_code="request_timeout",
|
||||
retryable=True,
|
||||
)
|
||||
if not emitted and await self._maybe_retry(
|
||||
timeout_error,
|
||||
attempt=attempt,
|
||||
response=None,
|
||||
):
|
||||
continue
|
||||
raise timeout_error from None
|
||||
except httpx.HTTPError:
|
||||
transport_error = sanitized_error(
|
||||
operation=operation,
|
||||
kind=ProviderErrorKind.TRANSPORT,
|
||||
provider_code="transport_error",
|
||||
retryable=True,
|
||||
)
|
||||
if not emitted and await self._maybe_retry(
|
||||
transport_error,
|
||||
attempt=attempt,
|
||||
response=None,
|
||||
):
|
||||
continue
|
||||
raise transport_error from None
|
||||
|
||||
raise AssertionError("bounded chat retry loop exhausted unexpectedly")
|
||||
|
||||
def _validate_messages(
|
||||
self,
|
||||
messages: object,
|
||||
*,
|
||||
max_tokens: int,
|
||||
operation: str,
|
||||
) -> tuple[ChatMessage, ...]:
|
||||
if isinstance(messages, (str, bytes)) or not isinstance(messages, Sequence):
|
||||
raise invalid_request(operation, "invalid_message_collection")
|
||||
validated = tuple(messages)
|
||||
if not validated:
|
||||
raise invalid_request(operation, "empty_messages")
|
||||
if isinstance(max_tokens, bool) or not isinstance(max_tokens, int) or max_tokens <= 0:
|
||||
raise invalid_request(operation, "invalid_max_tokens")
|
||||
for message in validated:
|
||||
if (
|
||||
not isinstance(message, ChatMessage)
|
||||
or message.role not in _ALLOWED_ROLES
|
||||
or not isinstance(message.content, str)
|
||||
or not message.content
|
||||
):
|
||||
raise invalid_request(operation, "invalid_message")
|
||||
return validated
|
||||
|
||||
def _payload(
|
||||
self,
|
||||
messages: tuple[ChatMessage, ...],
|
||||
*,
|
||||
max_tokens: int,
|
||||
stream: bool,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"model": self._model,
|
||||
"messages": [
|
||||
{"role": message.role, "content": message.content} for message in messages
|
||||
],
|
||||
"max_tokens": max_tokens,
|
||||
"stream": stream,
|
||||
"enable_thinking": False,
|
||||
"enable_search": False,
|
||||
}
|
||||
if stream:
|
||||
payload["stream_options"] = {"include_usage": True}
|
||||
return payload
|
||||
|
||||
def _parse_completion(
|
||||
self,
|
||||
body: Mapping[str, Any],
|
||||
*,
|
||||
operation: str,
|
||||
) -> tuple[str, str | None]:
|
||||
choices = body.get("choices")
|
||||
if not isinstance(choices, list) or len(choices) != 1:
|
||||
raise invalid_response(operation, "invalid_choices")
|
||||
choice = choices[0]
|
||||
if not isinstance(choice, Mapping):
|
||||
raise invalid_response(operation, "invalid_choice")
|
||||
message = choice.get("message")
|
||||
if not isinstance(message, Mapping):
|
||||
raise invalid_response(operation, "invalid_message")
|
||||
content = message.get("content")
|
||||
if not isinstance(content, str):
|
||||
raise invalid_response(operation, "invalid_content")
|
||||
finish_reason = choice.get("finish_reason")
|
||||
if finish_reason is not None and not isinstance(finish_reason, str):
|
||||
raise invalid_response(operation, "invalid_finish_reason")
|
||||
return content, finish_reason
|
||||
|
||||
def _decode_stream_event(
|
||||
self,
|
||||
raw_data: str,
|
||||
*,
|
||||
operation: str,
|
||||
) -> Mapping[str, Any]:
|
||||
try:
|
||||
decoded = json.loads(raw_data)
|
||||
except (TypeError, ValueError):
|
||||
raise invalid_response(operation, "invalid_stream_json") from None
|
||||
if not isinstance(decoded, Mapping):
|
||||
raise invalid_response(operation, "invalid_stream_object")
|
||||
return decoded
|
||||
|
||||
def _parse_stream_event(
|
||||
self,
|
||||
body: Mapping[str, Any],
|
||||
*,
|
||||
operation: str,
|
||||
sensitive_values: tuple[str, ...],
|
||||
elapsed_ms: float,
|
||||
) -> ChatStreamEvent:
|
||||
choices = body.get("choices")
|
||||
delta = ""
|
||||
finish_reason: str | None = None
|
||||
if choices not in (None, []):
|
||||
if not isinstance(choices, list) or len(choices) != 1:
|
||||
raise invalid_response(operation, "invalid_stream_choices")
|
||||
choice = choices[0]
|
||||
if not isinstance(choice, Mapping):
|
||||
raise invalid_response(operation, "invalid_stream_choice")
|
||||
raw_delta = choice.get("delta")
|
||||
if not isinstance(raw_delta, Mapping):
|
||||
raise invalid_response(operation, "invalid_stream_delta")
|
||||
content = raw_delta.get("content", "")
|
||||
if not isinstance(content, str):
|
||||
raise invalid_response(operation, "invalid_stream_content")
|
||||
delta = content
|
||||
raw_finish_reason = choice.get("finish_reason")
|
||||
if raw_finish_reason is not None and not isinstance(raw_finish_reason, str):
|
||||
raise invalid_response(operation, "invalid_stream_finish_reason")
|
||||
finish_reason = raw_finish_reason
|
||||
|
||||
usage = parse_usage(body.get("usage"))
|
||||
if choices in (None, []) and usage == ProviderUsage():
|
||||
raise invalid_response(operation, "empty_stream_event")
|
||||
return ChatStreamEvent(
|
||||
delta=delta,
|
||||
finish_reason=finish_reason,
|
||||
model=response_model(
|
||||
body,
|
||||
self._model,
|
||||
sensitive_values=(*sensitive_values, self._api_key),
|
||||
),
|
||||
request_id=extract_request_id(
|
||||
body,
|
||||
sensitive_values=(*sensitive_values, self._api_key),
|
||||
),
|
||||
usage=usage,
|
||||
elapsed_ms=elapsed_ms,
|
||||
)
|
||||
177
backend/app/adapters/bailian/embedding.py
Normal file
177
backend/app/adapters/bailian/embedding.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Alibaba Cloud Model Studio OpenAI-compatible embedding adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.bailian._base import (
|
||||
BailianHttpAdapter,
|
||||
conservative_utf8_token_count,
|
||||
count_tokens,
|
||||
extract_request_id,
|
||||
invalid_request,
|
||||
invalid_response,
|
||||
parse_usage,
|
||||
response_model,
|
||||
)
|
||||
from app.ports.model_providers import EmbeddingResult, TokenCounter
|
||||
|
||||
EMBEDDING_MAX_BATCH_SIZE = 10
|
||||
EMBEDDING_MAX_TOKENS_PER_TEXT = 8_192
|
||||
EMBEDDING_MAX_TOKENS_PER_REQUEST = 33_000
|
||||
EMBEDDING_DIMENSIONS = 1_024
|
||||
|
||||
|
||||
class BailianEmbeddingAdapter(BailianHttpAdapter):
|
||||
"""Call ``compatible-mode/v1/embeddings`` with strict input/output checks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str = "text-embedding-v4",
|
||||
dimensions: int = EMBEDDING_DIMENSIONS,
|
||||
token_counter: TokenCounter = conservative_utf8_token_count,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
max_retries: int = 0,
|
||||
retry_base_seconds: float = 0.5,
|
||||
) -> None:
|
||||
if not model or model != model.strip():
|
||||
raise invalid_request("embedding.configuration", "invalid_model")
|
||||
if isinstance(dimensions, bool) or dimensions != EMBEDDING_DIMENSIONS:
|
||||
raise invalid_request("embedding.configuration", "unsupported_dimensions")
|
||||
if not callable(token_counter):
|
||||
raise invalid_request("embedding.configuration", "invalid_token_counter")
|
||||
|
||||
super().__init__(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
expected_path="/compatible-mode/v1",
|
||||
http_client=http_client,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_retries=max_retries,
|
||||
retry_base_seconds=retry_base_seconds,
|
||||
)
|
||||
self._model = model
|
||||
self._dimensions = dimensions
|
||||
self._token_counter = token_counter
|
||||
|
||||
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult:
|
||||
operation = "embedding.create"
|
||||
validated_texts = self._validate_texts(texts, operation=operation)
|
||||
payload = {
|
||||
"model": self._model,
|
||||
"input": list(validated_texts),
|
||||
"dimensions": self._dimensions,
|
||||
"encoding_format": "float",
|
||||
}
|
||||
response = await self._post_json(
|
||||
operation=operation,
|
||||
path="embeddings",
|
||||
payload=payload,
|
||||
sensitive_values=validated_texts,
|
||||
)
|
||||
vectors = self._parse_vectors(
|
||||
response.body,
|
||||
expected_count=len(validated_texts),
|
||||
operation=operation,
|
||||
)
|
||||
return EmbeddingResult(
|
||||
vectors=vectors,
|
||||
model=response_model(
|
||||
response.body,
|
||||
self._model,
|
||||
sensitive_values=(*validated_texts, self._api_key),
|
||||
),
|
||||
request_id=extract_request_id(
|
||||
response.body,
|
||||
sensitive_values=(*validated_texts, self._api_key),
|
||||
),
|
||||
usage=parse_usage(response.body.get("usage")),
|
||||
elapsed_ms=response.elapsed_ms,
|
||||
)
|
||||
|
||||
async def embed_query(self, text: str) -> EmbeddingResult:
|
||||
return await self.embed_documents((text,))
|
||||
|
||||
def _validate_texts(
|
||||
self,
|
||||
texts: Sequence[str],
|
||||
*,
|
||||
operation: str,
|
||||
) -> tuple[str, ...]:
|
||||
if isinstance(texts, (str, bytes)) or not isinstance(texts, Sequence):
|
||||
raise invalid_request(operation, "invalid_input_collection")
|
||||
validated = tuple(texts)
|
||||
if not validated:
|
||||
raise invalid_request(operation, "empty_input")
|
||||
if len(validated) > EMBEDDING_MAX_BATCH_SIZE:
|
||||
raise invalid_request(operation, "batch_size_exceeded")
|
||||
|
||||
total_tokens = 0
|
||||
for text in validated:
|
||||
if not isinstance(text, str) or not text:
|
||||
raise invalid_request(operation, "invalid_input_text")
|
||||
token_count = count_tokens(
|
||||
self._token_counter,
|
||||
text,
|
||||
operation=operation,
|
||||
)
|
||||
if token_count > EMBEDDING_MAX_TOKENS_PER_TEXT:
|
||||
raise invalid_request(operation, "text_token_limit_exceeded")
|
||||
total_tokens += token_count
|
||||
if total_tokens > EMBEDDING_MAX_TOKENS_PER_REQUEST:
|
||||
raise invalid_request(operation, "request_token_limit_exceeded")
|
||||
return validated
|
||||
|
||||
def _parse_vectors(
|
||||
self,
|
||||
body: Mapping[str, Any],
|
||||
*,
|
||||
expected_count: int,
|
||||
operation: str,
|
||||
) -> tuple[tuple[float, ...], ...]:
|
||||
data = body.get("data")
|
||||
if not isinstance(data, list) or len(data) != expected_count:
|
||||
raise invalid_response(operation, "invalid_embedding_count")
|
||||
|
||||
by_index: list[tuple[float, ...] | None] = [None] * expected_count
|
||||
for item in data:
|
||||
if not isinstance(item, Mapping):
|
||||
raise invalid_response(operation, "invalid_embedding_item")
|
||||
index = item.get("index")
|
||||
if (
|
||||
isinstance(index, bool)
|
||||
or not isinstance(index, int)
|
||||
or not 0 <= index < expected_count
|
||||
or by_index[index] is not None
|
||||
):
|
||||
raise invalid_response(operation, "invalid_embedding_index")
|
||||
|
||||
raw_vector = item.get("embedding")
|
||||
if not isinstance(raw_vector, list) or len(raw_vector) != self._dimensions:
|
||||
raise invalid_response(operation, "invalid_embedding_dimensions")
|
||||
|
||||
vector: list[float] = []
|
||||
for component in raw_vector:
|
||||
if isinstance(component, bool) or not isinstance(component, (int, float)):
|
||||
raise invalid_response(operation, "invalid_embedding_component")
|
||||
normalized = float(component)
|
||||
if not math.isfinite(normalized):
|
||||
raise invalid_response(operation, "invalid_embedding_component")
|
||||
vector.append(normalized)
|
||||
|
||||
norm = math.hypot(*vector)
|
||||
if not math.isfinite(norm) or norm <= 0:
|
||||
raise invalid_response(operation, "invalid_embedding_norm")
|
||||
by_index[index] = tuple(vector)
|
||||
|
||||
if any(vector is None for vector in by_index):
|
||||
raise invalid_response(operation, "missing_embedding_index")
|
||||
return tuple(vector for vector in by_index if vector is not None)
|
||||
208
backend/app/adapters/bailian/rerank.py
Normal file
208
backend/app/adapters/bailian/rerank.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Alibaba Cloud Model Studio compatible rerank adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.bailian._base import (
|
||||
BailianHttpAdapter,
|
||||
conservative_utf8_token_count,
|
||||
count_tokens,
|
||||
extract_request_id,
|
||||
invalid_request,
|
||||
invalid_response,
|
||||
parse_usage,
|
||||
response_model,
|
||||
)
|
||||
from app.ports.model_providers import RankedItem, RerankResult, TokenCounter
|
||||
|
||||
RERANK_MAX_DOCUMENTS = 500
|
||||
RERANK_MAX_TOKENS_PER_TEXT = 4_000
|
||||
RERANK_MAX_TOKENS_PER_REQUEST = 120_000
|
||||
|
||||
|
||||
class BailianRerankerAdapter(BailianHttpAdapter):
|
||||
"""Call ``compatible-api/v1/reranks`` and map indices locally."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str = "qwen3-rerank",
|
||||
token_counter: TokenCounter = conservative_utf8_token_count,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
max_retries: int = 0,
|
||||
retry_base_seconds: float = 0.5,
|
||||
) -> None:
|
||||
if not model or model != model.strip():
|
||||
raise invalid_request("rerank.configuration", "invalid_model")
|
||||
if not callable(token_counter):
|
||||
raise invalid_request("rerank.configuration", "invalid_token_counter")
|
||||
|
||||
super().__init__(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
expected_path="/compatible-api/v1",
|
||||
http_client=http_client,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_retries=max_retries,
|
||||
retry_base_seconds=retry_base_seconds,
|
||||
)
|
||||
self._model = model
|
||||
self._token_counter = token_counter
|
||||
|
||||
async def rerank(
|
||||
self,
|
||||
query: str,
|
||||
documents: Sequence[str],
|
||||
*,
|
||||
top_n: int,
|
||||
instruct: str | None = None,
|
||||
) -> RerankResult:
|
||||
operation = "rerank.create"
|
||||
validated_documents = self._validate_request(
|
||||
query=query,
|
||||
documents=documents,
|
||||
top_n=top_n,
|
||||
instruct=instruct,
|
||||
operation=operation,
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"model": self._model,
|
||||
"query": query,
|
||||
"documents": list(validated_documents),
|
||||
"top_n": top_n,
|
||||
}
|
||||
if instruct is not None:
|
||||
payload["instruct"] = instruct
|
||||
|
||||
sensitive_values = (
|
||||
query,
|
||||
*validated_documents,
|
||||
*((instruct,) if instruct is not None else ()),
|
||||
)
|
||||
response = await self._post_json(
|
||||
operation=operation,
|
||||
path="reranks",
|
||||
payload=payload,
|
||||
sensitive_values=sensitive_values,
|
||||
)
|
||||
items = self._parse_results(
|
||||
response.body,
|
||||
documents=validated_documents,
|
||||
top_n=top_n,
|
||||
operation=operation,
|
||||
)
|
||||
return RerankResult(
|
||||
items=items,
|
||||
model=response_model(
|
||||
response.body,
|
||||
self._model,
|
||||
sensitive_values=(*sensitive_values, self._api_key),
|
||||
),
|
||||
request_id=extract_request_id(
|
||||
response.body,
|
||||
sensitive_values=(*sensitive_values, self._api_key),
|
||||
),
|
||||
usage=parse_usage(response.body.get("usage")),
|
||||
elapsed_ms=response.elapsed_ms,
|
||||
)
|
||||
|
||||
def _validate_request(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
documents: Sequence[str],
|
||||
top_n: int,
|
||||
instruct: str | None,
|
||||
operation: str,
|
||||
) -> tuple[str, ...]:
|
||||
if not isinstance(query, str) or not query:
|
||||
raise invalid_request(operation, "invalid_query")
|
||||
if isinstance(documents, (str, bytes)) or not isinstance(documents, Sequence):
|
||||
raise invalid_request(operation, "invalid_document_collection")
|
||||
validated_documents = tuple(documents)
|
||||
if not validated_documents:
|
||||
raise invalid_request(operation, "empty_documents")
|
||||
if len(validated_documents) > RERANK_MAX_DOCUMENTS:
|
||||
raise invalid_request(operation, "document_count_exceeded")
|
||||
if isinstance(top_n, bool) or not isinstance(top_n, int) or top_n <= 0:
|
||||
raise invalid_request(operation, "invalid_top_n")
|
||||
if instruct is not None and (not isinstance(instruct, str) or not instruct):
|
||||
raise invalid_request(operation, "invalid_instruct")
|
||||
|
||||
query_tokens = count_tokens(
|
||||
self._token_counter,
|
||||
query,
|
||||
operation=operation,
|
||||
)
|
||||
if query_tokens > RERANK_MAX_TOKENS_PER_TEXT:
|
||||
raise invalid_request(operation, "query_token_limit_exceeded")
|
||||
|
||||
document_tokens_total = 0
|
||||
for document in validated_documents:
|
||||
if not isinstance(document, str) or not document:
|
||||
raise invalid_request(operation, "invalid_document")
|
||||
document_tokens = count_tokens(
|
||||
self._token_counter,
|
||||
document,
|
||||
operation=operation,
|
||||
)
|
||||
if document_tokens > RERANK_MAX_TOKENS_PER_TEXT:
|
||||
raise invalid_request(operation, "document_token_limit_exceeded")
|
||||
document_tokens_total += document_tokens
|
||||
|
||||
request_tokens = query_tokens * len(validated_documents) + document_tokens_total
|
||||
if request_tokens > RERANK_MAX_TOKENS_PER_REQUEST:
|
||||
raise invalid_request(operation, "request_token_limit_exceeded")
|
||||
return validated_documents
|
||||
|
||||
def _parse_results(
|
||||
self,
|
||||
body: Mapping[str, Any],
|
||||
*,
|
||||
documents: tuple[str, ...],
|
||||
top_n: int,
|
||||
operation: str,
|
||||
) -> tuple[RankedItem, ...]:
|
||||
results = body.get("results")
|
||||
if not isinstance(results, list) or len(results) > min(top_n, len(documents)):
|
||||
raise invalid_response(operation, "invalid_rerank_count")
|
||||
|
||||
seen_indices: set[int] = set()
|
||||
parsed: list[RankedItem] = []
|
||||
previous_score = math.inf
|
||||
for item in results:
|
||||
if not isinstance(item, Mapping):
|
||||
raise invalid_response(operation, "invalid_rerank_item")
|
||||
index = item.get("index")
|
||||
if (
|
||||
isinstance(index, bool)
|
||||
or not isinstance(index, int)
|
||||
or not 0 <= index < len(documents)
|
||||
or index in seen_indices
|
||||
):
|
||||
raise invalid_response(operation, "invalid_rerank_index")
|
||||
raw_score = item.get("relevance_score")
|
||||
if isinstance(raw_score, bool) or not isinstance(raw_score, (int, float)):
|
||||
raise invalid_response(operation, "invalid_rerank_score")
|
||||
score = float(raw_score)
|
||||
if not math.isfinite(score) or not 0 <= score <= 1 or score > previous_score:
|
||||
raise invalid_response(operation, "invalid_rerank_score")
|
||||
|
||||
seen_indices.add(index)
|
||||
previous_score = score
|
||||
parsed.append(
|
||||
RankedItem(
|
||||
index=index,
|
||||
relevance_score=score,
|
||||
document=documents[index],
|
||||
)
|
||||
)
|
||||
return tuple(parsed)
|
||||
Reference in New Issue
Block a user