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
327 lines
12 KiB
Python
327 lines
12 KiB
Python
"""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,
|
|
)
|