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:
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,
|
||||
)
|
||||
Reference in New Issue
Block a user