Isolate cloud model access before enabling product RAG workflows
Some checks failed
verify / verify (push) Has been cancelled
Some checks failed
verify / verify (push) Has been cancelled
The API and ingestion tools now use a fixed internal model gateway while governed profiles, embedding cache assignments, traceable citations, and stable API errors establish the boundaries required by later workflows. Constraint: The current Alibaba Cloud workspace rejects all three live model calls with authentication failures Rejected: Give the API or seed tools the Bailian key and direct egress | combines database access, cloud credentials, and public network access Rejected: Mix offline and Bailian vectors in one demo namespace | makes profile activation and retrieval ambiguous Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep Bailian credentials and egress exclusive to model-gateway and create a new immutable profile hash for any embedding identity change Tested: make verify; 121 backend tests; 14 frontend tests; fresh and populated Alembic upgrade-downgrade-upgrade; two idempotent offline seeds; Docker health and HTTP retrieval; isolated provider smoke Not-tested: Successful live Bailian responses because the supplied workspace credential currently fails authentication
This commit is contained in:
539
backend/app/adapters/model_gateway.py
Normal file
539
backend/app/adapters/model_gateway.py
Normal file
@@ -0,0 +1,539 @@
|
||||
"""Typed client for the fixed internal model-gateway trust boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import Any, Literal, Self
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.bailian._base import (
|
||||
extract_request_id,
|
||||
invalid_request,
|
||||
invalid_response,
|
||||
parse_usage,
|
||||
response_model,
|
||||
safe_identifier,
|
||||
sanitized_error,
|
||||
)
|
||||
from app.core.config import Settings
|
||||
from app.core.secrets import read_secret_file
|
||||
from app.ports.model_providers import (
|
||||
ChatCompletionResult,
|
||||
ChatMessage,
|
||||
ChatStreamEvent,
|
||||
EmbeddingResult,
|
||||
ModelProviderError,
|
||||
ProviderErrorKind,
|
||||
ProviderUsage,
|
||||
RankedItem,
|
||||
RerankResult,
|
||||
)
|
||||
|
||||
_GATEWAY_HOST = "model-gateway"
|
||||
_GATEWAY_PORT = 8000
|
||||
_DIMENSION = 1024
|
||||
_ALLOWED_ROLES = frozenset({"system", "user", "assistant"})
|
||||
|
||||
|
||||
class ModelGatewayAdapter:
|
||||
"""Expose internal gateway calls through the provider-neutral model ports."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
caller: Literal["api", "worker"],
|
||||
base_url: str = "http://model-gateway:8000",
|
||||
embedding_model: str = "text-embedding-v4",
|
||||
rerank_model: str = "qwen3-rerank",
|
||||
chat_model: str = "deepseek-v4-flash",
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
timeout_seconds: float = 120.0,
|
||||
) -> None:
|
||||
if not token or token != token.strip():
|
||||
raise invalid_request("model_gateway.configuration", "invalid_token")
|
||||
if caller not in ("api", "worker"):
|
||||
raise invalid_request("model_gateway.configuration", "invalid_caller")
|
||||
self._base_url = self._validate_base_url(base_url)
|
||||
self._token = token
|
||||
self._caller = caller
|
||||
self._embedding_model = embedding_model
|
||||
self._rerank_model = rerank_model
|
||||
self._chat_model = chat_model
|
||||
self._owns_client = http_client is None
|
||||
self._client = http_client or httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(timeout_seconds),
|
||||
follow_redirects=False,
|
||||
trust_env=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_settings(
|
||||
cls,
|
||||
settings: Settings,
|
||||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> Self:
|
||||
return cls(
|
||||
token=read_secret_file(settings.model_gateway_token_file),
|
||||
caller=settings.model_gateway_caller,
|
||||
base_url=settings.model_gateway_base_url,
|
||||
embedding_model=settings.embedding_model,
|
||||
rerank_model=settings.rerank_model,
|
||||
chat_model=settings.llm_model,
|
||||
http_client=http_client,
|
||||
timeout_seconds=settings.model_gateway_timeout_seconds,
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._owns_client:
|
||||
await self._client.aclose()
|
||||
|
||||
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult:
|
||||
return await self._embed(texts, input_type="document")
|
||||
|
||||
async def embed_query(self, text: str) -> EmbeddingResult:
|
||||
return await self._embed((text,), input_type="query")
|
||||
|
||||
async def _embed(
|
||||
self,
|
||||
texts: Sequence[str],
|
||||
*,
|
||||
input_type: Literal["document", "query"],
|
||||
) -> EmbeddingResult:
|
||||
operation = f"model_gateway.embedding.{input_type}"
|
||||
validated = self._texts(texts, operation=operation, maximum=10)
|
||||
if input_type == "query" and len(validated) != 1:
|
||||
raise invalid_request(operation, "query_requires_one_text")
|
||||
body = await self._post_json(
|
||||
operation=operation,
|
||||
path="embeddings",
|
||||
payload={"texts": list(validated), "input_type": input_type},
|
||||
)
|
||||
vectors = self._vectors(body, expected=len(validated), operation=operation)
|
||||
return EmbeddingResult(
|
||||
vectors=vectors,
|
||||
model=response_model(body, self._embedding_model, sensitive_values=validated),
|
||||
request_id=extract_request_id(body, sensitive_values=validated),
|
||||
usage=parse_usage(body.get("usage")),
|
||||
elapsed_ms=self._elapsed(body, operation=operation),
|
||||
)
|
||||
|
||||
async def rerank(
|
||||
self,
|
||||
query: str,
|
||||
documents: Sequence[str],
|
||||
*,
|
||||
top_n: int,
|
||||
instruct: str | None = None,
|
||||
) -> RerankResult:
|
||||
operation = "model_gateway.rerank"
|
||||
if not isinstance(query, str) or not query:
|
||||
raise invalid_request(operation, "invalid_query")
|
||||
validated = self._texts(documents, operation=operation, maximum=500)
|
||||
if (
|
||||
isinstance(top_n, bool)
|
||||
or not isinstance(top_n, int)
|
||||
or not 1 <= top_n <= len(validated)
|
||||
):
|
||||
raise invalid_request(operation, "invalid_top_n")
|
||||
payload: dict[str, Any] = {
|
||||
"query": query,
|
||||
"documents": list(validated),
|
||||
"top_n": top_n,
|
||||
}
|
||||
if instruct is not None:
|
||||
if not isinstance(instruct, str) or not instruct:
|
||||
raise invalid_request(operation, "invalid_instruct")
|
||||
payload["instruct"] = instruct
|
||||
body = await self._post_json(operation=operation, path="rerank", payload=payload)
|
||||
raw_items = body.get("items")
|
||||
if not isinstance(raw_items, list) or len(raw_items) > top_n:
|
||||
raise invalid_response(operation, "invalid_items")
|
||||
items: list[RankedItem] = []
|
||||
seen: set[int] = set()
|
||||
for raw_item in raw_items:
|
||||
if not isinstance(raw_item, Mapping):
|
||||
raise invalid_response(operation, "invalid_item")
|
||||
index = raw_item.get("index")
|
||||
score = raw_item.get("relevance_score")
|
||||
document = raw_item.get("document")
|
||||
if (
|
||||
isinstance(index, bool)
|
||||
or not isinstance(index, int)
|
||||
or not 0 <= index < len(validated)
|
||||
or index in seen
|
||||
or isinstance(score, bool)
|
||||
or not isinstance(score, (int, float))
|
||||
or not math.isfinite(float(score))
|
||||
or document != validated[index]
|
||||
):
|
||||
raise invalid_response(operation, "invalid_item")
|
||||
seen.add(index)
|
||||
items.append(
|
||||
RankedItem(index=index, relevance_score=float(score), document=validated[index])
|
||||
)
|
||||
return RerankResult(
|
||||
items=tuple(items),
|
||||
model=response_model(body, self._rerank_model, sensitive_values=(query, *validated)),
|
||||
request_id=extract_request_id(body, sensitive_values=(query, *validated)),
|
||||
usage=parse_usage(body.get("usage")),
|
||||
elapsed_ms=self._elapsed(body, operation=operation),
|
||||
)
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
messages: Sequence[ChatMessage],
|
||||
*,
|
||||
max_tokens: int,
|
||||
) -> ChatCompletionResult:
|
||||
operation = "model_gateway.chat.complete"
|
||||
validated = self._messages(messages, max_tokens=max_tokens, operation=operation)
|
||||
body = await self._post_json(
|
||||
operation=operation,
|
||||
path="chat/completions",
|
||||
payload={
|
||||
"messages": [
|
||||
{"role": message.role, "content": message.content} for message in validated
|
||||
],
|
||||
"max_tokens": max_tokens,
|
||||
},
|
||||
)
|
||||
content = body.get("content")
|
||||
finish_reason = body.get("finish_reason")
|
||||
if not isinstance(content, str) or (
|
||||
finish_reason is not None and not isinstance(finish_reason, str)
|
||||
):
|
||||
raise invalid_response(operation, "invalid_completion")
|
||||
sensitive = tuple(message.content for message in validated)
|
||||
return ChatCompletionResult(
|
||||
content=content,
|
||||
finish_reason=finish_reason,
|
||||
model=response_model(body, self._chat_model, sensitive_values=sensitive),
|
||||
request_id=extract_request_id(body, sensitive_values=sensitive),
|
||||
usage=parse_usage(body.get("usage")),
|
||||
elapsed_ms=self._elapsed(body, operation=operation),
|
||||
)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: Sequence[ChatMessage],
|
||||
*,
|
||||
max_tokens: int,
|
||||
) -> AsyncIterator[ChatStreamEvent]:
|
||||
operation = "model_gateway.chat.stream"
|
||||
validated = self._messages(messages, max_tokens=max_tokens, operation=operation)
|
||||
payload = {
|
||||
"messages": [
|
||||
{"role": message.role, "content": message.content} for message in validated
|
||||
],
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
try:
|
||||
async with self._client.stream(
|
||||
"POST",
|
||||
self._url("chat/stream"),
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
await response.aread()
|
||||
self._raise_http_error(operation=operation, response=response)
|
||||
event_name: str | None = None
|
||||
complete_seen = False
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
event_name = None
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
event_name = line[6:].strip()
|
||||
continue
|
||||
if not line.startswith("data:") or event_name is None:
|
||||
raise invalid_response(operation, "invalid_sse_event")
|
||||
body = self._json_object(line[5:].strip(), operation=operation)
|
||||
if event_name == "error":
|
||||
self._raise_stream_error(body, operation=operation)
|
||||
if event_name not in {"delta", "complete"}:
|
||||
raise invalid_response(operation, "unsupported_sse_event")
|
||||
if complete_seen:
|
||||
raise invalid_response(operation, "event_after_complete")
|
||||
terminal = event_name == "complete"
|
||||
if terminal:
|
||||
complete_seen = True
|
||||
yield self._stream_event(
|
||||
body,
|
||||
operation=operation,
|
||||
terminal=terminal,
|
||||
)
|
||||
if not complete_seen:
|
||||
raise invalid_response(operation, "missing_complete_event")
|
||||
except ModelProviderError:
|
||||
raise
|
||||
except httpx.TimeoutException:
|
||||
raise sanitized_error(
|
||||
operation=operation,
|
||||
kind=ProviderErrorKind.TIMEOUT,
|
||||
provider_code="request_timeout",
|
||||
retryable=True,
|
||||
) from None
|
||||
except httpx.HTTPError:
|
||||
raise sanitized_error(
|
||||
operation=operation,
|
||||
kind=ProviderErrorKind.TRANSPORT,
|
||||
provider_code="transport_error",
|
||||
retryable=True,
|
||||
) from None
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
"X-RAG-Caller": self._caller,
|
||||
}
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self._base_url}/internal/v1/{path.lstrip('/')}"
|
||||
|
||||
async def _post_json(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
path: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
try:
|
||||
response = await self._client.post(
|
||||
self._url(path), headers=self._headers(), json=payload
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise sanitized_error(
|
||||
operation=operation,
|
||||
kind=ProviderErrorKind.TIMEOUT,
|
||||
provider_code="request_timeout",
|
||||
retryable=True,
|
||||
) from None
|
||||
except httpx.HTTPError:
|
||||
raise sanitized_error(
|
||||
operation=operation,
|
||||
kind=ProviderErrorKind.TRANSPORT,
|
||||
provider_code="transport_error",
|
||||
retryable=True,
|
||||
) from None
|
||||
if response.status_code >= 400:
|
||||
self._raise_http_error(operation=operation, response=response)
|
||||
return self._json_object(response.text, operation=operation)
|
||||
|
||||
def _raise_http_error(self, *, operation: str, response: httpx.Response) -> None:
|
||||
status = response.status_code
|
||||
if status == 400 or status == 422:
|
||||
kind, retryable = ProviderErrorKind.INVALID_REQUEST, False
|
||||
elif status == 401:
|
||||
kind, retryable = ProviderErrorKind.AUTHENTICATION, False
|
||||
elif status == 403:
|
||||
kind, retryable = ProviderErrorKind.PERMISSION_DENIED, False
|
||||
elif status == 404:
|
||||
kind, retryable = ProviderErrorKind.NOT_FOUND, False
|
||||
elif status == 408 or status == 504:
|
||||
kind, retryable = ProviderErrorKind.TIMEOUT, True
|
||||
elif status == 429:
|
||||
kind, retryable = ProviderErrorKind.RATE_LIMITED, True
|
||||
else:
|
||||
kind, retryable = ProviderErrorKind.UPSTREAM, status >= 500
|
||||
|
||||
# The trusted gateway exposes only a fixed provider-neutral error object.
|
||||
# Preserve that category for diagnostics while discarding all other body data.
|
||||
request_id: str | None = None
|
||||
try:
|
||||
decoded = response.json()
|
||||
except (ValueError, httpx.ResponseNotRead):
|
||||
decoded = None
|
||||
if isinstance(decoded, Mapping):
|
||||
raw_error = decoded.get("error")
|
||||
if isinstance(raw_error, Mapping):
|
||||
try:
|
||||
kind = ProviderErrorKind(str(raw_error.get("kind")))
|
||||
except ValueError:
|
||||
pass
|
||||
retryable = raw_error.get("retryable") is True
|
||||
request_id = extract_request_id(
|
||||
raw_error,
|
||||
sensitive_values=(self._token,),
|
||||
)
|
||||
raise sanitized_error(
|
||||
operation=operation,
|
||||
kind=kind,
|
||||
status_code=status,
|
||||
provider_code="model_gateway_rejected",
|
||||
request_id=request_id,
|
||||
retryable=retryable,
|
||||
)
|
||||
|
||||
def _raise_stream_error(self, body: Mapping[str, Any], *, operation: str) -> None:
|
||||
raw_kind = body.get("kind")
|
||||
try:
|
||||
kind = ProviderErrorKind(str(raw_kind))
|
||||
except ValueError:
|
||||
kind = ProviderErrorKind.UPSTREAM
|
||||
retryable = body.get("retryable") is True
|
||||
request_id = body.get("request_id")
|
||||
raise sanitized_error(
|
||||
operation=operation,
|
||||
kind=kind,
|
||||
provider_code="model_gateway_stream_error",
|
||||
request_id=request_id if isinstance(request_id, str) else None,
|
||||
retryable=retryable,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_base_url(value: str) -> str:
|
||||
normalized = value.rstrip("/")
|
||||
parsed = urlsplit(normalized)
|
||||
if (
|
||||
parsed.scheme != "http"
|
||||
or parsed.hostname != _GATEWAY_HOST
|
||||
or parsed.port != _GATEWAY_PORT
|
||||
or parsed.path not in ("", "/")
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise invalid_request("model_gateway.configuration", "invalid_base_url")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _texts(
|
||||
values: Sequence[str],
|
||||
*,
|
||||
operation: str,
|
||||
maximum: int,
|
||||
) -> tuple[str, ...]:
|
||||
if isinstance(values, (str, bytes)) or not isinstance(values, Sequence):
|
||||
raise invalid_request(operation, "invalid_text_collection")
|
||||
validated = tuple(values)
|
||||
if not validated or len(validated) > maximum:
|
||||
raise invalid_request(operation, "invalid_text_count")
|
||||
if any(not isinstance(value, str) or not value for value in validated):
|
||||
raise invalid_request(operation, "invalid_text")
|
||||
return validated
|
||||
|
||||
@staticmethod
|
||||
def _messages(
|
||||
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 or isinstance(max_tokens, bool) or not isinstance(max_tokens, int):
|
||||
raise invalid_request(operation, "invalid_messages")
|
||||
if not 1 <= max_tokens <= 8192:
|
||||
raise invalid_request(operation, "invalid_max_tokens")
|
||||
if any(
|
||||
not isinstance(message, ChatMessage)
|
||||
or message.role not in _ALLOWED_ROLES
|
||||
or not message.content
|
||||
for message in validated
|
||||
):
|
||||
raise invalid_request(operation, "invalid_message")
|
||||
return validated
|
||||
|
||||
@staticmethod
|
||||
def _json_object(value: str, *, operation: str) -> Mapping[str, Any]:
|
||||
try:
|
||||
body = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
raise invalid_response(operation, "invalid_json") from None
|
||||
if not isinstance(body, Mapping):
|
||||
raise invalid_response(operation, "invalid_json_object")
|
||||
return body
|
||||
|
||||
@staticmethod
|
||||
def _elapsed(body: Mapping[str, Any], *, operation: str) -> float:
|
||||
value = body.get("elapsed_ms")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise invalid_response(operation, "invalid_elapsed_ms")
|
||||
elapsed = float(value)
|
||||
if not math.isfinite(elapsed) or elapsed < 0:
|
||||
raise invalid_response(operation, "invalid_elapsed_ms")
|
||||
return elapsed
|
||||
|
||||
@staticmethod
|
||||
def _vectors(
|
||||
body: Mapping[str, Any],
|
||||
*,
|
||||
expected: int,
|
||||
operation: str,
|
||||
) -> tuple[tuple[float, ...], ...]:
|
||||
raw_vectors = body.get("vectors")
|
||||
if not isinstance(raw_vectors, list) or len(raw_vectors) != expected:
|
||||
raise invalid_response(operation, "invalid_embedding_count")
|
||||
vectors: list[tuple[float, ...]] = []
|
||||
for raw_vector in raw_vectors:
|
||||
if not isinstance(raw_vector, list) or len(raw_vector) != _DIMENSION:
|
||||
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")
|
||||
number = float(component)
|
||||
if not math.isfinite(number):
|
||||
raise invalid_response(operation, "invalid_embedding_component")
|
||||
vector.append(number)
|
||||
if math.hypot(*vector) <= 0:
|
||||
raise invalid_response(operation, "invalid_embedding_norm")
|
||||
vectors.append(tuple(vector))
|
||||
return tuple(vectors)
|
||||
|
||||
def _stream_event(
|
||||
self,
|
||||
body: Mapping[str, Any],
|
||||
*,
|
||||
operation: str,
|
||||
terminal: bool,
|
||||
) -> ChatStreamEvent:
|
||||
delta = body.get("delta", "")
|
||||
finish_reason = body.get("finish_reason")
|
||||
if not isinstance(delta, str) or (
|
||||
finish_reason is not None and not isinstance(finish_reason, str)
|
||||
):
|
||||
raise invalid_response(operation, "invalid_stream_event")
|
||||
raw_model = body.get("model")
|
||||
model = safe_identifier(raw_model, sensitive_values=(self._token,))
|
||||
if model is None:
|
||||
raise invalid_response(operation, "invalid_stream_model")
|
||||
request_id = extract_request_id(body, sensitive_values=(self._token,))
|
||||
if body.get("request_id") is not None and request_id is None:
|
||||
raise invalid_response(operation, "invalid_stream_request_id")
|
||||
if terminal and "elapsed_ms" not in body:
|
||||
raise invalid_response(operation, "missing_elapsed_ms")
|
||||
elapsed = body.get("elapsed_ms", 0.0)
|
||||
if isinstance(elapsed, bool) or not isinstance(elapsed, (int, float)):
|
||||
raise invalid_response(operation, "invalid_elapsed_ms")
|
||||
normalized_elapsed = float(elapsed)
|
||||
if not math.isfinite(normalized_elapsed) or normalized_elapsed < 0:
|
||||
raise invalid_response(operation, "invalid_elapsed_ms")
|
||||
if terminal and not isinstance(body.get("usage"), Mapping):
|
||||
raise invalid_response(operation, "missing_usage")
|
||||
return ChatStreamEvent(
|
||||
delta=delta,
|
||||
finish_reason=finish_reason,
|
||||
model=model,
|
||||
request_id=request_id,
|
||||
usage=parse_usage(body.get("usage")) if "usage" in body else ProviderUsage(),
|
||||
elapsed_ms=normalized_elapsed,
|
||||
)
|
||||
@@ -35,6 +35,11 @@ class Settings(BaseSettings):
|
||||
upload_root: Path = Path("/data/uploads")
|
||||
max_upload_mb: int = Field(default=100, ge=1, le=2048)
|
||||
|
||||
model_gateway_base_url: str = "http://model-gateway:8000"
|
||||
model_gateway_token_file: Path = Path("/run/secrets/model_gateway_api_token")
|
||||
model_gateway_caller: Literal["api", "worker"] = "api"
|
||||
model_gateway_timeout_seconds: float = Field(default=120, gt=0, le=600)
|
||||
|
||||
bailian_openai_base_url: str = (
|
||||
"https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||
)
|
||||
@@ -71,6 +76,24 @@ class Settings(BaseSettings):
|
||||
def normalize_base_url(cls, value: str) -> str:
|
||||
return value.rstrip("/")
|
||||
|
||||
@field_validator("model_gateway_base_url")
|
||||
@classmethod
|
||||
def validate_model_gateway_base_url(cls, value: str) -> str:
|
||||
normalized = value.rstrip("/")
|
||||
parsed = urlsplit(normalized)
|
||||
if (
|
||||
parsed.scheme != "http"
|
||||
or parsed.hostname != "model-gateway"
|
||||
or parsed.port != 8000
|
||||
or parsed.path not in ("", "/")
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError("MODEL_GATEWAY_BASE_URL must be the fixed internal service URL")
|
||||
return normalized
|
||||
|
||||
@field_validator("embedding_dimension", mode="before")
|
||||
@classmethod
|
||||
def parse_embedding_dimension(cls, value: object) -> int:
|
||||
|
||||
62
backend/app/core/problems.py
Normal file
62
backend/app/core/problems.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Stable RFC 9457-style problem responses for public API failures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
PROBLEM_MEDIA_TYPE = "application/problem+json"
|
||||
PROBLEM_BASE = "https://geological-rag.local/problems"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApiProblem(Exception):
|
||||
"""An intentionally public and sanitized application failure."""
|
||||
|
||||
status: int
|
||||
code: str
|
||||
title: str
|
||||
detail: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
Exception.__init__(self, self.code)
|
||||
|
||||
|
||||
def problem_payload(
|
||||
*,
|
||||
status: int,
|
||||
code: str,
|
||||
title: str,
|
||||
detail: str,
|
||||
trace_id: str,
|
||||
field_errors: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the only public error envelope used by formal product routes."""
|
||||
|
||||
return {
|
||||
"type": f"{PROBLEM_BASE}/{code.lower().replace('_', '-')}",
|
||||
"title": title,
|
||||
"status": status,
|
||||
"code": code,
|
||||
"detail": detail,
|
||||
"trace_id": trace_id,
|
||||
"field_errors": field_errors or [],
|
||||
}
|
||||
|
||||
|
||||
def api_problem_handler(request: Request, exc: ApiProblem) -> JSONResponse:
|
||||
trace_id = str(getattr(request.state, "trace_id", "unavailable"))
|
||||
return JSONResponse(
|
||||
status_code=exc.status,
|
||||
media_type=PROBLEM_MEDIA_TYPE,
|
||||
content=problem_payload(
|
||||
status=exc.status,
|
||||
code=exc.code,
|
||||
title=exc.title,
|
||||
detail=exc.detail,
|
||||
trace_id=trace_id,
|
||||
),
|
||||
)
|
||||
30
backend/app/core/request_context.py
Normal file
30
backend/app/core/request_context.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Per-request trace context with a bounded, non-secret public identifier."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
REQUEST_ID_HEADER = "x-request-id"
|
||||
type CallNext = Callable[[Request], Awaitable[Response]]
|
||||
|
||||
|
||||
def _request_id(value: str | None) -> str:
|
||||
if value is not None:
|
||||
try:
|
||||
return str(uuid.UUID(value))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
async def trace_request(request: Request, call_next: CallNext) -> Response:
|
||||
"""Attach a UUID trace ID and return it without trusting arbitrary input."""
|
||||
|
||||
trace_id = _request_id(request.headers.get(REQUEST_ID_HEADER))
|
||||
request.state.trace_id = trace_id
|
||||
response = await call_next(request)
|
||||
response.headers[REQUEST_ID_HEADER] = trace_id
|
||||
return response
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FastAPI entrypoint with dependency-free liveness and database readiness probes."""
|
||||
"""FastAPI application factory and production entrypoint."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -9,26 +9,17 @@ from fastapi import FastAPI, Response, status
|
||||
from app import __version__
|
||||
from app.api.v1 import demo_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.problems import ApiProblem, api_problem_handler
|
||||
from app.core.request_context import trace_request
|
||||
from app.core.secrets import SecretFileError
|
||||
|
||||
app = FastAPI(title="Geological RAG API", version=__version__)
|
||||
app.include_router(demo_router)
|
||||
|
||||
type HealthPayload = dict[str, str | dict[str, str]]
|
||||
|
||||
|
||||
@app.get("/health/live", tags=["health"])
|
||||
@app.get("/api/v1/health/live", tags=["health"])
|
||||
def live() -> dict[str, str]:
|
||||
return {"status": "ok", "version": __version__}
|
||||
|
||||
|
||||
@app.get(
|
||||
"/health/ready",
|
||||
tags=["health"],
|
||||
responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Database unavailable"}},
|
||||
)
|
||||
@app.get("/api/v1/health/ready", tags=["health"])
|
||||
def ready(response: Response) -> HealthPayload:
|
||||
settings = get_settings()
|
||||
try:
|
||||
@@ -48,7 +39,6 @@ def ready(response: Response) -> HealthPayload:
|
||||
return {"status": "ready", "checks": {"database": "ok"}}
|
||||
|
||||
|
||||
@app.get("/api/v1/meta", tags=["meta"])
|
||||
def meta() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
return {
|
||||
@@ -63,6 +53,64 @@ def meta() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create the API without opening a database or loading model credentials."""
|
||||
|
||||
api = FastAPI(
|
||||
title="Geological RAG API",
|
||||
version=__version__,
|
||||
openapi_tags=[
|
||||
{"name": "health", "description": "Process and database health probes."},
|
||||
{"name": "meta", "description": "Safe runtime capability metadata."},
|
||||
{"name": "offline-demo", "description": "Synthetic offline validation only."},
|
||||
],
|
||||
)
|
||||
api.middleware("http")(trace_request)
|
||||
api.add_exception_handler(ApiProblem, api_problem_handler) # type: ignore[arg-type]
|
||||
api.include_router(demo_router)
|
||||
api.add_api_route(
|
||||
"/health/live",
|
||||
live,
|
||||
methods=["GET"],
|
||||
tags=["health"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
api.add_api_route(
|
||||
"/api/v1/health/live",
|
||||
live,
|
||||
methods=["GET"],
|
||||
tags=["health"],
|
||||
operation_id="getLiveness",
|
||||
)
|
||||
api.add_api_route(
|
||||
"/health/ready",
|
||||
ready,
|
||||
methods=["GET"],
|
||||
tags=["health"],
|
||||
include_in_schema=False,
|
||||
responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Database unavailable"}},
|
||||
)
|
||||
api.add_api_route(
|
||||
"/api/v1/health/ready",
|
||||
ready,
|
||||
methods=["GET"],
|
||||
tags=["health"],
|
||||
operation_id="getReadiness",
|
||||
responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Database unavailable"}},
|
||||
)
|
||||
api.add_api_route(
|
||||
"/api/v1/meta",
|
||||
meta,
|
||||
methods=["GET"],
|
||||
tags=["meta"],
|
||||
operation_id="getRuntimeMetadata",
|
||||
)
|
||||
return api
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Compose publishes this listener only on the host loopback interface.
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000) # noqa: S104
|
||||
|
||||
755
backend/app/model_gateway.py
Normal file
755
backend/app/model_gateway.py
Normal file
@@ -0,0 +1,755 @@
|
||||
"""Credential-isolated internal gateway for all cloud model capabilities."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal, Protocol, Self, runtime_checkable
|
||||
|
||||
from fastapi import Depends, FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from app import __version__
|
||||
from app.adapters.bailian import (
|
||||
BailianChatAdapter,
|
||||
BailianEmbeddingAdapter,
|
||||
BailianRerankerAdapter,
|
||||
)
|
||||
from app.core.config import Settings
|
||||
from app.core.secrets import SecretFileError, read_secret_file
|
||||
from app.ports.model_providers import (
|
||||
ChatMessage,
|
||||
ChatProvider,
|
||||
ChatStreamEvent,
|
||||
EmbeddingProvider,
|
||||
ModelProviderError,
|
||||
ProviderErrorKind,
|
||||
ProviderUsage,
|
||||
Reranker,
|
||||
)
|
||||
|
||||
Caller = Literal["api", "worker"]
|
||||
InputType = Literal["document", "query"]
|
||||
Role = Literal["system", "user", "assistant"]
|
||||
SettingsFactory = Callable[[], Settings]
|
||||
CALLERS: tuple[Caller, Caller] = ("api", "worker")
|
||||
|
||||
DEFAULT_ALLOWED_TOKEN_FILES = (
|
||||
"/run/secrets/model_gateway_api_token,/run/secrets/model_gateway_worker_token" # noqa: S105
|
||||
)
|
||||
MAX_CHAT_MESSAGES = 100
|
||||
MAX_CHAT_CONTENT_CHARS = 100_000
|
||||
MAX_CHAT_OUTPUT_TOKENS = 8_192
|
||||
ALLOWED_FINISH_REASONS = frozenset(
|
||||
{"stop", "length", "content_filter", "tool_calls", "function_call"}
|
||||
)
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class EmbeddingRequest(_StrictModel):
|
||||
texts: list[Annotated[str, Field(min_length=1, max_length=8_192)]] = Field(
|
||||
min_length=1,
|
||||
max_length=10,
|
||||
)
|
||||
input_type: InputType
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_count(self) -> Self:
|
||||
if self.input_type == "query" and len(self.texts) != 1:
|
||||
raise ValueError("query embedding accepts exactly one text")
|
||||
return self
|
||||
|
||||
|
||||
class RerankRequest(_StrictModel):
|
||||
query: str = Field(min_length=1, max_length=4_000)
|
||||
documents: list[Annotated[str, Field(min_length=1, max_length=4_000)]] = Field(
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
top_n: int = Field(ge=1, le=500)
|
||||
instruct: str | None = Field(default=None, min_length=1, max_length=4_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_top_n(self) -> Self:
|
||||
if self.top_n > len(self.documents):
|
||||
raise ValueError("top_n must not exceed document count")
|
||||
return self
|
||||
|
||||
|
||||
class ChatMessageRequest(_StrictModel):
|
||||
role: Role
|
||||
content: str = Field(min_length=1, max_length=MAX_CHAT_CONTENT_CHARS)
|
||||
|
||||
|
||||
class ChatRequest(_StrictModel):
|
||||
messages: list[ChatMessageRequest] = Field(min_length=1, max_length=MAX_CHAT_MESSAGES)
|
||||
max_tokens: int = Field(default=1_024, ge=1, le=MAX_CHAT_OUTPUT_TOKENS)
|
||||
|
||||
|
||||
class UsageResponse(_StrictModel):
|
||||
input_tokens: int | None
|
||||
output_tokens: int | None
|
||||
total_tokens: int | None
|
||||
|
||||
|
||||
class EmbeddingResponse(_StrictModel):
|
||||
vectors: list[list[float]]
|
||||
model: str
|
||||
request_id: str | None
|
||||
usage: UsageResponse
|
||||
elapsed_ms: float
|
||||
|
||||
|
||||
class RankedItemResponse(_StrictModel):
|
||||
index: int
|
||||
relevance_score: float
|
||||
document: str
|
||||
|
||||
|
||||
class RerankResponse(_StrictModel):
|
||||
items: list[RankedItemResponse]
|
||||
model: str
|
||||
request_id: str | None
|
||||
usage: UsageResponse
|
||||
elapsed_ms: float
|
||||
|
||||
|
||||
class ChatResponse(_StrictModel):
|
||||
content: str
|
||||
finish_reason: str | None
|
||||
model: str
|
||||
request_id: str | None
|
||||
usage: UsageResponse
|
||||
elapsed_ms: float
|
||||
|
||||
|
||||
class _UnauthorizedError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _ForbiddenError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _UnavailableError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _RestartRequiredError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _SupportsAclose(Protocol):
|
||||
async def aclose(self) -> None: ...
|
||||
|
||||
|
||||
class _ClosingStreamingResponse(StreamingResponse):
|
||||
"""Close the response iterator on completion, cancellation, or send failure."""
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
try:
|
||||
await super().__call__(scope, receive, send)
|
||||
finally:
|
||||
await _close_stream(self.body_iterator)
|
||||
|
||||
|
||||
class _Runtime:
|
||||
def __init__(self) -> None:
|
||||
self.embedding: EmbeddingProvider | None = None
|
||||
self.reranker: Reranker | None = None
|
||||
self.chat: ChatProvider | None = None
|
||||
self.semaphore: asyncio.Semaphore | None = None
|
||||
self.allowed_tokens: dict[Caller, str] = {}
|
||||
self.local_configuration_check: Callable[[], bool] = lambda: False
|
||||
self.restart_required = False
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return all(
|
||||
provider is not None
|
||||
for provider in (self.embedding, self.reranker, self.chat, self.semaphore)
|
||||
) and set(self.allowed_tokens) == {"api", "worker"}
|
||||
|
||||
def invalidate_for_restart(self) -> None:
|
||||
"""Atomically stop accepting work after mounted configuration changes."""
|
||||
self.restart_required = True
|
||||
self.embedding = None
|
||||
self.reranker = None
|
||||
self.chat = None
|
||||
self.semaphore = None
|
||||
self.allowed_tokens = {}
|
||||
|
||||
|
||||
def _usage_response(usage: ProviderUsage) -> UsageResponse:
|
||||
return UsageResponse(
|
||||
input_tokens=usage.input_tokens,
|
||||
output_tokens=usage.output_tokens,
|
||||
total_tokens=usage.total_tokens,
|
||||
)
|
||||
|
||||
|
||||
def _merge_usage(current: ProviderUsage, update: ProviderUsage) -> ProviderUsage:
|
||||
return ProviderUsage(
|
||||
input_tokens=(
|
||||
update.input_tokens if update.input_tokens is not None else current.input_tokens
|
||||
),
|
||||
output_tokens=(
|
||||
update.output_tokens if update.output_tokens is not None else current.output_tokens
|
||||
),
|
||||
total_tokens=(
|
||||
update.total_tokens if update.total_tokens is not None else current.total_tokens
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _boundary_error(operation: str) -> ModelProviderError:
|
||||
return ModelProviderError(
|
||||
operation=operation,
|
||||
kind=ProviderErrorKind.INVALID_RESPONSE,
|
||||
provider_code="gateway_boundary_error",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_allowed_tokens(allowed_tokens: Mapping[str, str]) -> dict[Caller, str]:
|
||||
if set(allowed_tokens) != {"api", "worker"}:
|
||||
raise ValueError("allowed_tokens must define api and worker identities")
|
||||
normalized: dict[Caller, str] = {}
|
||||
for caller in CALLERS:
|
||||
token = allowed_tokens.get(caller)
|
||||
if (
|
||||
not isinstance(token, str)
|
||||
or not token
|
||||
or token != token.strip()
|
||||
or "\n" in token
|
||||
or "\r" in token
|
||||
or len(token) > 4_096
|
||||
):
|
||||
raise ValueError("allowed token is invalid")
|
||||
normalized[caller] = token
|
||||
if secrets.compare_digest(normalized["api"], normalized["worker"]):
|
||||
raise ValueError("api and worker tokens must be different")
|
||||
return normalized
|
||||
|
||||
|
||||
def _load_allowed_tokens_from_files() -> dict[Caller, str]:
|
||||
raw = os.environ.get("MODEL_GATEWAY_ALLOWED_TOKEN_FILES", DEFAULT_ALLOWED_TOKEN_FILES)
|
||||
entries = raw.split(",")
|
||||
if len(entries) != 2 or any(not entry.strip() for entry in entries):
|
||||
raise ValueError("MODEL_GATEWAY_ALLOWED_TOKEN_FILES must define two files")
|
||||
|
||||
paths: dict[str, str] = {}
|
||||
if all("=" not in entry for entry in entries):
|
||||
paths = {caller: entry.strip() for caller, entry in zip(CALLERS, entries, strict=True)}
|
||||
else:
|
||||
for entry in entries:
|
||||
caller, separator, path = entry.partition("=")
|
||||
if not separator or caller.strip() in paths:
|
||||
raise ValueError("invalid model gateway token file mapping")
|
||||
paths[caller.strip()] = path.strip()
|
||||
if set(paths) != {"api", "worker"}:
|
||||
raise ValueError("model gateway token files must map api and worker")
|
||||
|
||||
return _normalize_allowed_tokens(
|
||||
{
|
||||
"api": read_secret_file(Path(paths["api"])),
|
||||
"worker": read_secret_file(Path(paths["worker"])),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _provider_status(error: ModelProviderError) -> int:
|
||||
return {
|
||||
ProviderErrorKind.INVALID_REQUEST: status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
ProviderErrorKind.RATE_LIMITED: status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
ProviderErrorKind.TIMEOUT: status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
ProviderErrorKind.TRANSPORT: status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
}.get(error.kind, status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
|
||||
def _error_payload(
|
||||
kind: str,
|
||||
*,
|
||||
retryable: bool = False,
|
||||
request_id: str | None = None,
|
||||
) -> dict[str, dict[str, str | bool | None]]:
|
||||
return {
|
||||
"error": {
|
||||
"kind": kind,
|
||||
"retryable": retryable,
|
||||
"request_id": request_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _sse(event: str, payload: Mapping[str, object]) -> bytes:
|
||||
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
return f"event: {event}\ndata: {data}\n\n".encode()
|
||||
|
||||
|
||||
async def _close_stream(stream: object | None) -> None:
|
||||
if isinstance(stream, _SupportsAclose):
|
||||
try:
|
||||
await stream.aclose()
|
||||
except Exception:
|
||||
# Closing is best-effort and must never expose provider exception text.
|
||||
return
|
||||
|
||||
|
||||
def create_model_gateway_app(
|
||||
*,
|
||||
embedding_provider: EmbeddingProvider | None = None,
|
||||
reranker: Reranker | None = None,
|
||||
chat_provider: ChatProvider | None = None,
|
||||
allowed_tokens: Mapping[str, str] | None = None,
|
||||
max_concurrency: int | None = None,
|
||||
settings_factory: SettingsFactory = Settings,
|
||||
) -> FastAPI:
|
||||
"""Create the internal model gateway with injectable hermetic providers."""
|
||||
|
||||
injected = (embedding_provider, reranker, chat_provider)
|
||||
if any(provider is not None for provider in injected) != all(
|
||||
provider is not None for provider in injected
|
||||
):
|
||||
raise ValueError("all three providers must be injected together")
|
||||
providers_are_injected = all(provider is not None for provider in injected)
|
||||
if providers_are_injected != (allowed_tokens is not None):
|
||||
raise ValueError("injected providers and allowed_tokens must be supplied together")
|
||||
if max_concurrency is not None and (
|
||||
isinstance(max_concurrency, bool) or max_concurrency < 1 or max_concurrency > 100
|
||||
):
|
||||
raise ValueError("max_concurrency must be between 1 and 100")
|
||||
|
||||
runtime = _Runtime()
|
||||
owned_adapters: list[BailianEmbeddingAdapter | BailianRerankerAdapter | BailianChatAdapter] = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
if providers_are_injected:
|
||||
assert embedding_provider is not None
|
||||
assert reranker is not None
|
||||
assert chat_provider is not None
|
||||
assert allowed_tokens is not None
|
||||
runtime.embedding = embedding_provider
|
||||
runtime.reranker = reranker
|
||||
runtime.chat = chat_provider
|
||||
runtime.allowed_tokens = _normalize_allowed_tokens(allowed_tokens)
|
||||
runtime.semaphore = asyncio.Semaphore(max_concurrency or 4)
|
||||
runtime.local_configuration_check = lambda: True
|
||||
runtime.restart_required = False
|
||||
else:
|
||||
try:
|
||||
settings = settings_factory()
|
||||
api_key = settings.bailian_api_key()
|
||||
loaded_tokens = _load_allowed_tokens_from_files()
|
||||
|
||||
embedding_adapter = BailianEmbeddingAdapter(
|
||||
api_key=api_key,
|
||||
base_url=settings.bailian_openai_base_url,
|
||||
model=settings.embedding_model,
|
||||
dimensions=settings.embedding_dimension,
|
||||
timeout_seconds=settings.model_timeout_seconds,
|
||||
max_retries=settings.model_max_retries,
|
||||
)
|
||||
owned_adapters.append(embedding_adapter)
|
||||
rerank_adapter = BailianRerankerAdapter(
|
||||
api_key=api_key,
|
||||
base_url=settings.bailian_rerank_base_url,
|
||||
model=settings.rerank_model,
|
||||
timeout_seconds=settings.model_timeout_seconds,
|
||||
max_retries=settings.model_max_retries,
|
||||
)
|
||||
owned_adapters.append(rerank_adapter)
|
||||
chat_adapter = BailianChatAdapter(
|
||||
api_key=api_key,
|
||||
base_url=settings.bailian_openai_base_url,
|
||||
model=settings.llm_model,
|
||||
timeout_seconds=settings.model_timeout_seconds,
|
||||
max_retries=settings.model_max_retries,
|
||||
)
|
||||
owned_adapters.append(chat_adapter)
|
||||
|
||||
runtime.embedding = embedding_adapter
|
||||
runtime.reranker = rerank_adapter
|
||||
runtime.chat = chat_adapter
|
||||
runtime.allowed_tokens = loaded_tokens
|
||||
runtime.semaphore = asyncio.Semaphore(
|
||||
max_concurrency or settings.model_max_concurrency
|
||||
)
|
||||
runtime.restart_required = False
|
||||
|
||||
def check_local_configuration() -> bool:
|
||||
try:
|
||||
current_settings = settings_factory()
|
||||
current_api_key = current_settings.bailian_api_key()
|
||||
current_tokens = _load_allowed_tokens_from_files()
|
||||
same_key = secrets.compare_digest(current_api_key, api_key)
|
||||
same_tokens = all(
|
||||
secrets.compare_digest(current_tokens[caller], loaded_tokens[caller])
|
||||
for caller in CALLERS
|
||||
)
|
||||
same_provider_contract = (
|
||||
current_settings.bailian_openai_base_url
|
||||
== settings.bailian_openai_base_url
|
||||
and current_settings.bailian_rerank_base_url
|
||||
== settings.bailian_rerank_base_url
|
||||
and current_settings.embedding_model == settings.embedding_model
|
||||
and current_settings.embedding_dimension == settings.embedding_dimension
|
||||
and current_settings.rerank_model == settings.rerank_model
|
||||
and current_settings.llm_model == settings.llm_model
|
||||
)
|
||||
return same_key and same_tokens and same_provider_contract
|
||||
except (OSError, SecretFileError, ValueError, ModelProviderError):
|
||||
return False
|
||||
|
||||
runtime.local_configuration_check = check_local_configuration
|
||||
except (OSError, SecretFileError, ValueError, ModelProviderError):
|
||||
runtime.local_configuration_check = lambda: False
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
runtime.embedding = None
|
||||
runtime.reranker = None
|
||||
runtime.chat = None
|
||||
runtime.semaphore = None
|
||||
runtime.allowed_tokens = {}
|
||||
runtime.local_configuration_check = lambda: False
|
||||
runtime.restart_required = False
|
||||
for adapter in reversed(owned_adapters):
|
||||
await adapter.aclose()
|
||||
owned_adapters.clear()
|
||||
|
||||
gateway = FastAPI(
|
||||
title="Geological RAG Model Gateway",
|
||||
version=__version__,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@gateway.exception_handler(RequestValidationError)
|
||||
async def request_validation_error(
|
||||
_: Request,
|
||||
__: RequestValidationError,
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
content=_error_payload("invalid_request"),
|
||||
)
|
||||
|
||||
@gateway.exception_handler(ModelProviderError)
|
||||
async def model_provider_error(_: Request, error: ModelProviderError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=_provider_status(error),
|
||||
content=_error_payload(
|
||||
error.kind.value,
|
||||
retryable=error.retryable,
|
||||
request_id=error.request_id,
|
||||
),
|
||||
)
|
||||
|
||||
@gateway.exception_handler(_UnauthorizedError)
|
||||
async def unauthorized_error(_: Request, __: _UnauthorizedError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content=_error_payload("unauthorized"),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@gateway.exception_handler(_ForbiddenError)
|
||||
async def forbidden_error(_: Request, __: _ForbiddenError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content=_error_payload("forbidden"),
|
||||
)
|
||||
|
||||
@gateway.exception_handler(_UnavailableError)
|
||||
async def unavailable_error(_: Request, __: _UnavailableError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
content=_error_payload("unavailable", retryable=True),
|
||||
)
|
||||
|
||||
@gateway.exception_handler(_RestartRequiredError)
|
||||
async def restart_required_error(_: Request, __: _RestartRequiredError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
content=_error_payload("restart_required"),
|
||||
)
|
||||
|
||||
@gateway.exception_handler(Exception)
|
||||
async def unexpected_error(_: Request, __: Exception) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=_error_payload("internal_error"),
|
||||
)
|
||||
|
||||
def ensure_current_configuration() -> None:
|
||||
if runtime.restart_required:
|
||||
raise _RestartRequiredError
|
||||
if not runtime.available:
|
||||
raise _UnavailableError
|
||||
if not runtime.local_configuration_check():
|
||||
runtime.invalidate_for_restart()
|
||||
raise _RestartRequiredError
|
||||
|
||||
def require_runtime() -> tuple[
|
||||
EmbeddingProvider,
|
||||
Reranker,
|
||||
ChatProvider,
|
||||
asyncio.Semaphore,
|
||||
]:
|
||||
ensure_current_configuration()
|
||||
assert runtime.embedding is not None
|
||||
assert runtime.reranker is not None
|
||||
assert runtime.chat is not None
|
||||
assert runtime.semaphore is not None
|
||||
return runtime.embedding, runtime.reranker, runtime.chat, runtime.semaphore
|
||||
|
||||
async def authorize(request: Request) -> Caller:
|
||||
ensure_current_configuration()
|
||||
authorization = request.headers.get("authorization", "")
|
||||
scheme, separator, credential = authorization.partition(" ")
|
||||
caller_value = request.headers.get("x-rag-caller", "")
|
||||
if (
|
||||
not separator
|
||||
or scheme.lower() != "bearer"
|
||||
or not credential
|
||||
or len(credential) > 4_096
|
||||
or caller_value not in {"api", "worker"}
|
||||
):
|
||||
raise _UnauthorizedError
|
||||
|
||||
matched_identity: Caller | None = None
|
||||
for identity, allowed_token in runtime.allowed_tokens.items():
|
||||
if secrets.compare_digest(credential, allowed_token):
|
||||
matched_identity = identity
|
||||
if matched_identity is None or matched_identity != caller_value:
|
||||
raise _UnauthorizedError
|
||||
return matched_identity
|
||||
|
||||
@gateway.get("/health/live", include_in_schema=False)
|
||||
async def live() -> dict[str, str]:
|
||||
return {"status": "ok", "version": __version__}
|
||||
|
||||
@gateway.get("/health/ready", include_in_schema=False)
|
||||
async def ready() -> JSONResponse:
|
||||
if runtime.restart_required:
|
||||
configuration_status = "restart_required"
|
||||
elif runtime.available and runtime.local_configuration_check():
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={"status": "ready", "checks": {"configuration": "ok"}},
|
||||
)
|
||||
elif runtime.available:
|
||||
runtime.invalidate_for_restart()
|
||||
configuration_status = "restart_required"
|
||||
else:
|
||||
configuration_status = "unavailable"
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
content={
|
||||
"status": "not_ready",
|
||||
"checks": {"configuration": configuration_status},
|
||||
},
|
||||
)
|
||||
|
||||
@gateway.post("/internal/v1/embeddings", response_model=EmbeddingResponse)
|
||||
async def embeddings(
|
||||
payload: EmbeddingRequest,
|
||||
caller: Annotated[Caller, Depends(authorize)],
|
||||
) -> EmbeddingResponse:
|
||||
embedding, _, _, semaphore = require_runtime()
|
||||
if payload.input_type == "document" and caller != "worker":
|
||||
raise _ForbiddenError
|
||||
try:
|
||||
async with semaphore:
|
||||
if payload.input_type == "query":
|
||||
result = await embedding.embed_query(payload.texts[0])
|
||||
else:
|
||||
result = await embedding.embed_documents(payload.texts)
|
||||
return EmbeddingResponse(
|
||||
vectors=[list(vector) for vector in result.vectors],
|
||||
model=result.model,
|
||||
request_id=result.request_id,
|
||||
usage=_usage_response(result.usage),
|
||||
elapsed_ms=result.elapsed_ms,
|
||||
)
|
||||
except ModelProviderError:
|
||||
raise
|
||||
except Exception:
|
||||
raise _boundary_error("model_gateway.embedding") from None
|
||||
|
||||
@gateway.post("/internal/v1/rerank", response_model=RerankResponse)
|
||||
async def rerank(
|
||||
payload: RerankRequest,
|
||||
_: Annotated[Caller, Depends(authorize)],
|
||||
) -> RerankResponse:
|
||||
embedding_provider_unused, reranker_provider, chat_provider_unused, semaphore = (
|
||||
require_runtime()
|
||||
)
|
||||
del embedding_provider_unused, chat_provider_unused
|
||||
try:
|
||||
async with semaphore:
|
||||
result = await reranker_provider.rerank(
|
||||
payload.query,
|
||||
payload.documents,
|
||||
top_n=payload.top_n,
|
||||
instruct=payload.instruct,
|
||||
)
|
||||
return RerankResponse(
|
||||
items=[
|
||||
RankedItemResponse(
|
||||
index=item.index,
|
||||
relevance_score=item.relevance_score,
|
||||
document=item.document,
|
||||
)
|
||||
for item in result.items
|
||||
],
|
||||
model=result.model,
|
||||
request_id=result.request_id,
|
||||
usage=_usage_response(result.usage),
|
||||
elapsed_ms=result.elapsed_ms,
|
||||
)
|
||||
except ModelProviderError:
|
||||
raise
|
||||
except Exception:
|
||||
raise _boundary_error("model_gateway.rerank") from None
|
||||
|
||||
def chat_messages(payload: ChatRequest) -> list[ChatMessage]:
|
||||
return [
|
||||
ChatMessage(role=message.role, content=message.content) for message in payload.messages
|
||||
]
|
||||
|
||||
@gateway.post("/internal/v1/chat/completions", response_model=ChatResponse)
|
||||
async def chat_completion(
|
||||
payload: ChatRequest,
|
||||
_: Annotated[Caller, Depends(authorize)],
|
||||
) -> ChatResponse:
|
||||
embedding_provider_unused, reranker_unused, chat, semaphore = require_runtime()
|
||||
del embedding_provider_unused, reranker_unused
|
||||
try:
|
||||
async with semaphore:
|
||||
result = await chat.complete(chat_messages(payload), max_tokens=payload.max_tokens)
|
||||
return ChatResponse(
|
||||
content=result.content,
|
||||
finish_reason=result.finish_reason,
|
||||
model=result.model,
|
||||
request_id=result.request_id,
|
||||
usage=_usage_response(result.usage),
|
||||
elapsed_ms=result.elapsed_ms,
|
||||
)
|
||||
except ModelProviderError:
|
||||
raise
|
||||
except Exception:
|
||||
raise _boundary_error("model_gateway.chat_complete") from None
|
||||
|
||||
@gateway.post("/internal/v1/chat/stream")
|
||||
async def chat_stream(
|
||||
payload: ChatRequest,
|
||||
_: Annotated[Caller, Depends(authorize)],
|
||||
) -> StreamingResponse:
|
||||
embedding_provider_unused, reranker_unused, chat, semaphore = require_runtime()
|
||||
del embedding_provider_unused, reranker_unused
|
||||
|
||||
async def event_stream() -> AsyncIterator[bytes]:
|
||||
events: AsyncIterator[ChatStreamEvent] | None = None
|
||||
finish_reason: str | None = None
|
||||
model: str | None = None
|
||||
request_id: str | None = None
|
||||
usage = ProviderUsage()
|
||||
elapsed_ms = 0.0
|
||||
try:
|
||||
events = chat.stream(chat_messages(payload), max_tokens=payload.max_tokens)
|
||||
async with semaphore:
|
||||
async for event in events:
|
||||
if event.finish_reason is not None:
|
||||
if event.finish_reason not in ALLOWED_FINISH_REASONS:
|
||||
raise ModelProviderError(
|
||||
operation="chat.stream",
|
||||
kind=ProviderErrorKind.INVALID_RESPONSE,
|
||||
provider_code="invalid_finish_reason",
|
||||
)
|
||||
finish_reason = event.finish_reason
|
||||
if event.model:
|
||||
model = event.model
|
||||
if event.request_id:
|
||||
request_id = event.request_id
|
||||
usage = _merge_usage(usage, event.usage)
|
||||
elapsed_ms = max(elapsed_ms, event.elapsed_ms)
|
||||
if event.delta:
|
||||
yield _sse(
|
||||
"delta",
|
||||
{
|
||||
"delta": event.delta,
|
||||
"finish_reason": (
|
||||
event.finish_reason
|
||||
if event.finish_reason in ALLOWED_FINISH_REASONS
|
||||
else None
|
||||
),
|
||||
"model": event.model or model,
|
||||
"request_id": event.request_id or request_id,
|
||||
},
|
||||
)
|
||||
if finish_reason is None or model is None:
|
||||
raise ModelProviderError(
|
||||
operation="chat.stream",
|
||||
kind=ProviderErrorKind.INVALID_RESPONSE,
|
||||
provider_code="missing_terminal_event",
|
||||
)
|
||||
yield _sse(
|
||||
"complete",
|
||||
{
|
||||
"finish_reason": finish_reason,
|
||||
"model": model,
|
||||
"request_id": request_id,
|
||||
"usage": _usage_response(usage).model_dump(),
|
||||
"elapsed_ms": elapsed_ms,
|
||||
},
|
||||
)
|
||||
except ModelProviderError as error:
|
||||
yield _sse(
|
||||
"error",
|
||||
{
|
||||
"kind": error.kind.value,
|
||||
"retryable": error.retryable,
|
||||
"request_id": error.request_id,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
boundary_failure = _boundary_error("model_gateway.chat_stream")
|
||||
yield _sse(
|
||||
"error",
|
||||
{
|
||||
"kind": boundary_failure.kind.value,
|
||||
"retryable": boundary_failure.retryable,
|
||||
"request_id": boundary_failure.request_id,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
await _close_stream(events)
|
||||
|
||||
return _ClosingStreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
return gateway
|
||||
|
||||
|
||||
app = create_model_gateway_app()
|
||||
@@ -9,11 +9,7 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.adapters.bailian import (
|
||||
BailianChatAdapter,
|
||||
BailianEmbeddingAdapter,
|
||||
BailianRerankerAdapter,
|
||||
)
|
||||
from app.adapters.model_gateway import ModelGatewayAdapter
|
||||
from app.core.config import Settings
|
||||
from app.core.secrets import SecretFileError
|
||||
from app.ports.model_providers import ChatMessage, ModelProviderError
|
||||
@@ -30,89 +26,59 @@ class ProbeResult:
|
||||
status_code: int | None = None
|
||||
|
||||
|
||||
async def probe_embedding(settings: Settings, api_key: str) -> ProbeResult:
|
||||
adapter = BailianEmbeddingAdapter(
|
||||
api_key=api_key,
|
||||
base_url=settings.bailian_openai_base_url,
|
||||
model=settings.embedding_model,
|
||||
dimensions=settings.embedding_dimension,
|
||||
timeout_seconds=settings.model_timeout_seconds,
|
||||
max_retries=settings.model_max_retries,
|
||||
async def probe_embedding(settings: Settings, adapter: ModelGatewayAdapter) -> ProbeResult:
|
||||
# API identity probes query embedding. Document embedding remains worker-only.
|
||||
result = await adapter.embed_query("用于能力探测的虚构地质问题。")
|
||||
if len(result.vectors) != 1 or len(result.vectors[0]) != settings.embedding_dimension:
|
||||
raise RuntimeError("embedding contract mismatch")
|
||||
return ProbeResult(
|
||||
capability="embedding",
|
||||
status="ok",
|
||||
model=result.model,
|
||||
elapsed_ms=round(result.elapsed_ms, 2),
|
||||
request_id=result.request_id,
|
||||
)
|
||||
try:
|
||||
result = await adapter.embed_documents(["用于能力探测的虚构地质文本。"])
|
||||
if len(result.vectors) != 1 or len(result.vectors[0]) != settings.embedding_dimension:
|
||||
raise RuntimeError("embedding contract mismatch")
|
||||
return ProbeResult(
|
||||
capability="embedding",
|
||||
status="ok",
|
||||
model=result.model,
|
||||
elapsed_ms=round(result.elapsed_ms, 2),
|
||||
request_id=result.request_id,
|
||||
)
|
||||
finally:
|
||||
await adapter.aclose()
|
||||
|
||||
|
||||
async def probe_rerank(settings: Settings, api_key: str) -> ProbeResult:
|
||||
adapter = BailianRerankerAdapter(
|
||||
api_key=api_key,
|
||||
base_url=settings.bailian_rerank_base_url,
|
||||
model=settings.rerank_model,
|
||||
timeout_seconds=settings.model_timeout_seconds,
|
||||
max_retries=settings.model_max_retries,
|
||||
async def probe_rerank(_: Settings, adapter: ModelGatewayAdapter) -> ProbeResult:
|
||||
result = await adapter.rerank(
|
||||
"哪段文本提到了斑岩铜矿?",
|
||||
["虚构斑岩铜矿具有钾化带。", "虚构煤层采用测井曲线对比。"],
|
||||
top_n=1,
|
||||
)
|
||||
try:
|
||||
result = await adapter.rerank(
|
||||
"哪段文本提到了斑岩铜矿?",
|
||||
["虚构斑岩铜矿具有钾化带。", "虚构煤层采用测井曲线对比。"],
|
||||
top_n=1,
|
||||
)
|
||||
if len(result.items) != 1 or result.items[0].index not in (0, 1):
|
||||
raise RuntimeError("rerank contract mismatch")
|
||||
return ProbeResult(
|
||||
capability="rerank",
|
||||
status="ok",
|
||||
model=result.model,
|
||||
elapsed_ms=round(result.elapsed_ms, 2),
|
||||
request_id=result.request_id,
|
||||
)
|
||||
finally:
|
||||
await adapter.aclose()
|
||||
|
||||
|
||||
async def probe_chat(settings: Settings, api_key: str) -> ProbeResult:
|
||||
adapter = BailianChatAdapter(
|
||||
api_key=api_key,
|
||||
base_url=settings.bailian_openai_base_url,
|
||||
model=settings.llm_model,
|
||||
timeout_seconds=settings.model_timeout_seconds,
|
||||
max_retries=settings.model_max_retries,
|
||||
if len(result.items) != 1 or result.items[0].index not in (0, 1):
|
||||
raise RuntimeError("rerank contract mismatch")
|
||||
return ProbeResult(
|
||||
capability="rerank",
|
||||
status="ok",
|
||||
model=result.model,
|
||||
elapsed_ms=round(result.elapsed_ms, 2),
|
||||
request_id=result.request_id,
|
||||
)
|
||||
|
||||
|
||||
async def probe_chat(_: Settings, adapter: ModelGatewayAdapter) -> ProbeResult:
|
||||
model: str | None = None
|
||||
request_id: str | None = None
|
||||
elapsed_ms = 0.0
|
||||
content_seen = False
|
||||
try:
|
||||
async for event in adapter.stream(
|
||||
[ChatMessage(role="user", content="只回复:能力正常")],
|
||||
max_tokens=16,
|
||||
):
|
||||
model = event.model
|
||||
request_id = event.request_id or request_id
|
||||
elapsed_ms = max(elapsed_ms, event.elapsed_ms)
|
||||
content_seen = content_seen or bool(event.delta)
|
||||
if not content_seen:
|
||||
raise RuntimeError("chat stream contained no text")
|
||||
return ProbeResult(
|
||||
capability="chat",
|
||||
status="ok",
|
||||
model=model,
|
||||
elapsed_ms=round(elapsed_ms, 2),
|
||||
request_id=request_id,
|
||||
)
|
||||
finally:
|
||||
await adapter.aclose()
|
||||
async for event in adapter.stream(
|
||||
[ChatMessage(role="user", content="只回复:能力正常")],
|
||||
max_tokens=16,
|
||||
):
|
||||
model = event.model
|
||||
request_id = event.request_id or request_id
|
||||
elapsed_ms = max(elapsed_ms, event.elapsed_ms)
|
||||
content_seen = content_seen or bool(event.delta)
|
||||
if not content_seen:
|
||||
raise RuntimeError("chat stream contained no text")
|
||||
return ProbeResult(
|
||||
capability="chat",
|
||||
status="ok",
|
||||
model=model,
|
||||
elapsed_ms=round(elapsed_ms, 2),
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
|
||||
def failed_probe(capability: str, error: BaseException) -> ProbeResult:
|
||||
@@ -133,12 +99,12 @@ def failed_probe(capability: str, error: BaseException) -> ProbeResult:
|
||||
|
||||
async def run_probe(
|
||||
capability: str,
|
||||
operation: Callable[[Settings, str], Awaitable[ProbeResult]],
|
||||
operation: Callable[[Settings, ModelGatewayAdapter], Awaitable[ProbeResult]],
|
||||
settings: Settings,
|
||||
api_key: str,
|
||||
adapter: ModelGatewayAdapter,
|
||||
) -> ProbeResult:
|
||||
try:
|
||||
return await operation(settings, api_key)
|
||||
return await operation(settings, adapter)
|
||||
except Exception as exc: # The output is deliberately reduced to a safe category.
|
||||
return failed_probe(capability, exc)
|
||||
|
||||
@@ -148,17 +114,10 @@ def write_json_line(payload: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
async def async_main() -> int:
|
||||
adapter: ModelGatewayAdapter | None = None
|
||||
try:
|
||||
settings = Settings()
|
||||
if any(
|
||||
"<workspace-id>" in url
|
||||
for url in (
|
||||
settings.bailian_openai_base_url,
|
||||
settings.bailian_rerank_base_url,
|
||||
)
|
||||
):
|
||||
raise ValueError("workspace endpoint placeholders are not runnable")
|
||||
api_key = settings.bailian_api_key()
|
||||
adapter = ModelGatewayAdapter.from_settings(settings)
|
||||
except (SecretFileError, ValueError):
|
||||
write_json_line(
|
||||
{
|
||||
@@ -174,12 +133,15 @@ async def async_main() -> int:
|
||||
("rerank", probe_rerank),
|
||||
("chat", probe_chat),
|
||||
)
|
||||
results = []
|
||||
for capability, operation in probes:
|
||||
result = await run_probe(capability, operation, settings, api_key)
|
||||
results.append(result)
|
||||
write_json_line(asdict(result))
|
||||
return 0 if all(result.status == "ok" for result in results) else 1
|
||||
try:
|
||||
results = []
|
||||
for capability, operation in probes:
|
||||
result = await run_probe(capability, operation, settings, adapter)
|
||||
results.append(result)
|
||||
write_json_line(asdict(result))
|
||||
return 0 if all(result.status == "ok" for result in results) else 1
|
||||
finally:
|
||||
await adapter.aclose()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -20,8 +20,8 @@ from pgvector.psycopg import register_vector
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.adapters.bailian import BailianEmbeddingAdapter, BailianRerankerAdapter
|
||||
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker, lexical_features
|
||||
from app.adapters.model_gateway import ModelGatewayAdapter
|
||||
from app.core.config import Settings
|
||||
from app.core.demo_identity import (
|
||||
ACCESS_SCOPE_ID,
|
||||
@@ -55,6 +55,42 @@ class DemoQuery:
|
||||
answerable: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DemoNamespace:
|
||||
mode: str
|
||||
knowledge_base_id: uuid.UUID
|
||||
access_scope_id: uuid.UUID
|
||||
scope_name: str
|
||||
knowledge_base_name: str
|
||||
storage_prefix: str
|
||||
|
||||
|
||||
OFFLINE_NAMESPACE = DemoNamespace(
|
||||
mode="fake",
|
||||
knowledge_base_id=KNOWLEDGE_BASE_ID,
|
||||
access_scope_id=ACCESS_SCOPE_ID,
|
||||
scope_name="synthetic-demo",
|
||||
knowledge_base_name="虚构地质 PoC 知识库(离线)",
|
||||
storage_prefix="synthetic/offline",
|
||||
)
|
||||
BAILIAN_NAMESPACE = DemoNamespace(
|
||||
mode="bailian",
|
||||
knowledge_base_id=uuid.uuid5(IDENTITY_NAMESPACE, "synthetic-bailian-knowledge-base"),
|
||||
access_scope_id=uuid.uuid5(IDENTITY_NAMESPACE, "synthetic-bailian-public-scope"),
|
||||
scope_name="synthetic-bailian-validation",
|
||||
knowledge_base_name="虚构地质 PoC 知识库(百炼验证)",
|
||||
storage_prefix="synthetic/bailian",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmbeddedVector:
|
||||
vector: tuple[float, ...]
|
||||
request_id: str | None
|
||||
usage: dict[str, int | None]
|
||||
elapsed_ms: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PreparedChunk:
|
||||
source_id: str
|
||||
@@ -71,6 +107,9 @@ class PreparedChunk:
|
||||
embedding_profile_hash: str
|
||||
vector: tuple[float, ...]
|
||||
embedding_model: str
|
||||
provider_request_id: str | None
|
||||
embedding_usage: dict[str, int | None]
|
||||
embedding_elapsed_ms: int
|
||||
title: str
|
||||
region: str
|
||||
mineral: str
|
||||
@@ -88,6 +127,14 @@ def sha256_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def namespace_for_mode(mode: str) -> DemoNamespace:
|
||||
if mode == "fake":
|
||||
return OFFLINE_NAMESPACE
|
||||
if mode == "bailian":
|
||||
return BAILIAN_NAMESPACE
|
||||
raise SeedContractError("invalid_provider_mode")
|
||||
|
||||
|
||||
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
if not path.is_file():
|
||||
raise SeedContractError("fixture_missing")
|
||||
@@ -142,8 +189,10 @@ def load_queries(path: Path) -> list[DemoQuery]:
|
||||
|
||||
|
||||
def embedding_profile_hash(settings: Settings, mode: str) -> str:
|
||||
if mode != "bailian":
|
||||
if mode == "fake":
|
||||
return offline_embedding_profile_hash(settings.embedding_dimension)
|
||||
if mode != "bailian":
|
||||
raise SeedContractError("invalid_provider_mode")
|
||||
|
||||
endpoint_identity = sha256_text(urlsplit(settings.bailian_openai_base_url).hostname or "")
|
||||
model = settings.embedding_model
|
||||
@@ -164,15 +213,30 @@ def embedding_profile_hash(settings: Settings, mode: str) -> str:
|
||||
async def embed_in_batches(
|
||||
provider: EmbeddingProvider,
|
||||
texts: Sequence[str],
|
||||
) -> tuple[tuple[tuple[float, ...], ...], str]:
|
||||
vectors: list[tuple[float, ...]] = []
|
||||
) -> tuple[tuple[EmbeddedVector, ...], str]:
|
||||
vectors: list[EmbeddedVector] = []
|
||||
resolved_model: str | None = None
|
||||
for offset in range(0, len(texts), 10):
|
||||
result = await provider.embed_documents(texts[offset : offset + 10])
|
||||
if resolved_model is not None and result.model != resolved_model:
|
||||
raise SeedContractError("embedding_model_changed_between_batches")
|
||||
resolved_model = result.model
|
||||
vectors.extend(result.vectors)
|
||||
if len(result.vectors) != len(texts[offset : offset + 10]):
|
||||
raise SeedContractError("embedding_batch_count_mismatch")
|
||||
usage = {
|
||||
"input_tokens": result.usage.input_tokens,
|
||||
"output_tokens": result.usage.output_tokens,
|
||||
"total_tokens": result.usage.total_tokens,
|
||||
}
|
||||
vectors.extend(
|
||||
EmbeddedVector(
|
||||
vector=vector,
|
||||
request_id=result.request_id,
|
||||
usage=usage,
|
||||
elapsed_ms=max(0, round(result.elapsed_ms)),
|
||||
)
|
||||
for vector in result.vectors
|
||||
)
|
||||
if len(vectors) != len(texts) or resolved_model is None:
|
||||
raise SeedContractError("embedding_result_count_mismatch")
|
||||
return tuple(vectors), resolved_model
|
||||
@@ -180,10 +244,11 @@ async def embed_in_batches(
|
||||
|
||||
def prepare_chunks(
|
||||
documents: Sequence[DemoDocument],
|
||||
vectors: Sequence[tuple[float, ...]],
|
||||
vectors: Sequence[EmbeddedVector],
|
||||
*,
|
||||
profile_hash: str,
|
||||
embedding_model: str,
|
||||
namespace: DemoNamespace = OFFLINE_NAMESPACE,
|
||||
) -> list[PreparedChunk]:
|
||||
prepared = []
|
||||
for document, vector in zip(documents, vectors, strict=True):
|
||||
@@ -200,7 +265,12 @@ def prepare_chunks(
|
||||
separators=(",", ":"),
|
||||
)
|
||||
raw_hash = sha256_text(raw_payload)
|
||||
document_id = uuid.uuid5(IDENTITY_NAMESPACE, f"document:{document.source_id}")
|
||||
document_identity = (
|
||||
f"document:{document.source_id}"
|
||||
if namespace.mode == "fake"
|
||||
else f"document:{namespace.mode}:{document.source_id}"
|
||||
)
|
||||
document_id = uuid.uuid5(IDENTITY_NAMESPACE, document_identity)
|
||||
version_id = uuid.uuid5(
|
||||
IDENTITY_NAMESPACE,
|
||||
f"version:{document.source_id}:{raw_hash}:{profile_hash}",
|
||||
@@ -237,8 +307,11 @@ def prepare_chunks(
|
||||
embedding_text_sha256=embedding_hash,
|
||||
outbound_manifest_sha256=sha256_text(manifest_payload),
|
||||
embedding_profile_hash=profile_hash,
|
||||
vector=vector,
|
||||
vector=vector.vector,
|
||||
embedding_model=embedding_model,
|
||||
provider_request_id=vector.request_id,
|
||||
embedding_usage=vector.usage,
|
||||
embedding_elapsed_ms=vector.elapsed_ms,
|
||||
title=document.title,
|
||||
region=document.region,
|
||||
mineral=document.mineral,
|
||||
@@ -255,20 +328,96 @@ def database_dsn(settings: Settings) -> str:
|
||||
)
|
||||
|
||||
|
||||
def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[str, int]:
|
||||
def write_chunks(
|
||||
settings: Settings,
|
||||
chunks: Sequence[PreparedChunk],
|
||||
*,
|
||||
namespace: DemoNamespace,
|
||||
) -> dict[str, int]:
|
||||
if not chunks:
|
||||
raise SeedContractError("chunks_empty")
|
||||
profile_hashes = {item.embedding_profile_hash for item in chunks}
|
||||
resolved_models = {item.embedding_model for item in chunks}
|
||||
if len(profile_hashes) != 1 or len(resolved_models) != 1:
|
||||
raise SeedContractError("mixed_embedding_profiles")
|
||||
profile_hash = next(iter(profile_hashes))
|
||||
resolved_model = next(iter(resolved_models))
|
||||
if namespace.mode == "fake":
|
||||
provider = "local-synthetic"
|
||||
api_mode = "deterministic-offline"
|
||||
endpoint_identity_hash = sha256_text("local-fake")
|
||||
else:
|
||||
provider = "aliyun-bailian"
|
||||
api_mode = "model-gateway/openai-compatible"
|
||||
endpoint_identity_hash = sha256_text(
|
||||
urlsplit(settings.bailian_openai_base_url).hostname or ""
|
||||
)
|
||||
|
||||
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
|
||||
register_vector(connection)
|
||||
connection.execute("SELECT pg_advisory_xact_lock(724202607120001)")
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rag.knowledge_bases (id, name, description)
|
||||
VALUES (%s, %s, %s)
|
||||
INSERT INTO rag.model_profiles (
|
||||
profile_hash, alias, kind, provider, model, api_mode, dimension,
|
||||
endpoint_identity_hash, config_snapshot, synthetic, enabled
|
||||
) VALUES (
|
||||
%s, %s, 'embedding', %s, %s, %s, 1024, %s, %s, %s, true
|
||||
)
|
||||
ON CONFLICT (profile_hash) DO NOTHING
|
||||
""",
|
||||
(
|
||||
profile_hash,
|
||||
f"{namespace.mode}-embedding-{profile_hash[:12]}",
|
||||
provider,
|
||||
resolved_model,
|
||||
api_mode,
|
||||
endpoint_identity_hash,
|
||||
Jsonb(
|
||||
{
|
||||
"dimension": settings.embedding_dimension,
|
||||
"requested_model": settings.embedding_model,
|
||||
"source": "synthetic-seed-v1",
|
||||
}
|
||||
),
|
||||
namespace.mode == "fake",
|
||||
),
|
||||
)
|
||||
registered_profile = connection.execute(
|
||||
"""
|
||||
SELECT kind, provider, model, api_mode, dimension, endpoint_identity_hash
|
||||
FROM rag.model_profiles
|
||||
WHERE profile_hash = %s
|
||||
""",
|
||||
(profile_hash,),
|
||||
).fetchone()
|
||||
if registered_profile is None or (
|
||||
registered_profile["kind"] != "embedding"
|
||||
or registered_profile["provider"] != provider
|
||||
or registered_profile["model"] != resolved_model
|
||||
or registered_profile["api_mode"] != api_mode
|
||||
or registered_profile["dimension"] != settings.embedding_dimension
|
||||
or registered_profile["endpoint_identity_hash"] != endpoint_identity_hash
|
||||
):
|
||||
raise SeedContractError("embedding_profile_collision")
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rag.knowledge_bases (
|
||||
id, name, description, active_embedding_profile_hash
|
||||
)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
active_embedding_profile_hash = EXCLUDED.active_embedding_profile_hash,
|
||||
updated_at = now()
|
||||
""",
|
||||
(KNOWLEDGE_BASE_ID, "虚构地质 PoC 知识库", "仅含公开的合成验证文本"),
|
||||
(
|
||||
namespace.knowledge_base_id,
|
||||
namespace.knowledge_base_name,
|
||||
"仅含公开的合成验证文本",
|
||||
profile_hash,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -276,7 +425,11 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
(ACCESS_SCOPE_ID, KNOWLEDGE_BASE_ID, "synthetic-demo"),
|
||||
(
|
||||
namespace.access_scope_id,
|
||||
namespace.knowledge_base_id,
|
||||
namespace.scope_name,
|
||||
),
|
||||
)
|
||||
|
||||
for item in chunks:
|
||||
@@ -295,11 +448,11 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
||||
""",
|
||||
(
|
||||
item.document_id,
|
||||
KNOWLEDGE_BASE_ID,
|
||||
ACCESS_SCOPE_ID,
|
||||
namespace.knowledge_base_id,
|
||||
namespace.access_scope_id,
|
||||
item.raw_sha256,
|
||||
f"{item.source_id}.json",
|
||||
f"synthetic/{item.source_id}",
|
||||
f"{namespace.storage_prefix}/{item.source_id}",
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
@@ -384,10 +537,10 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
||||
""",
|
||||
(
|
||||
item.chunk_id,
|
||||
KNOWLEDGE_BASE_ID,
|
||||
namespace.knowledge_base_id,
|
||||
item.document_id,
|
||||
item.version_id,
|
||||
ACCESS_SCOPE_ID,
|
||||
namespace.access_scope_id,
|
||||
item.cloud_text,
|
||||
item.cloud_text,
|
||||
item.cloud_text_sha256,
|
||||
@@ -425,6 +578,41 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
||||
""",
|
||||
(item.version_id,),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rag.embedding_cache (
|
||||
profile_hash, embedding_text_sha256, embedding, resolved_model,
|
||||
provider_request_id, usage, elapsed_ms
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (profile_hash, embedding_text_sha256) DO NOTHING
|
||||
""",
|
||||
(
|
||||
item.embedding_profile_hash,
|
||||
item.embedding_text_sha256,
|
||||
Vector(list(item.vector)),
|
||||
item.embedding_model,
|
||||
item.provider_request_id,
|
||||
Jsonb(item.embedding_usage),
|
||||
item.embedding_elapsed_ms,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rag.chunk_embedding_assignments (
|
||||
chunk_id, profile_hash, embedding_text_sha256,
|
||||
cache_profile_hash, cache_embedding_text_sha256,
|
||||
status, completed_at
|
||||
) VALUES (%s, %s, %s, %s, %s, 'READY', now())
|
||||
ON CONFLICT (chunk_id, profile_hash) DO NOTHING
|
||||
""",
|
||||
(
|
||||
item.chunk_id,
|
||||
item.embedding_profile_hash,
|
||||
item.embedding_text_sha256,
|
||||
item.embedding_profile_hash,
|
||||
item.embedding_text_sha256,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE rag.chunks
|
||||
@@ -459,8 +647,9 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
||||
count(*) FILTER (WHERE searchable)::integer AS searchable
|
||||
FROM rag.chunks
|
||||
WHERE knowledge_base_id = %s
|
||||
AND embedding_profile_hash = %s
|
||||
""",
|
||||
(KNOWLEDGE_BASE_ID,),
|
||||
(namespace.knowledge_base_id, profile_hash),
|
||||
).fetchone()
|
||||
if counts is None:
|
||||
raise SeedContractError("database_count_missing")
|
||||
@@ -470,6 +659,9 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
||||
def retrieve(
|
||||
settings: Settings,
|
||||
query_vector: tuple[float, ...],
|
||||
*,
|
||||
namespace: DemoNamespace,
|
||||
profile_hash: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
|
||||
register_vector(connection)
|
||||
@@ -477,19 +669,25 @@ def retrieve(
|
||||
connection.execute("SET LOCAL hnsw.ef_search = 100")
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id, metadata, embedding_text,
|
||||
1 - (embedding <=> %s) AS vector_score
|
||||
FROM rag.chunks
|
||||
WHERE searchable
|
||||
AND knowledge_base_id = %s
|
||||
AND access_scope_id = %s
|
||||
ORDER BY embedding <=> %s
|
||||
SELECT chunk.id, chunk.metadata, chunk.embedding_text,
|
||||
1 - (chunk.embedding <=> %s) AS vector_score
|
||||
FROM rag.chunks AS chunk
|
||||
JOIN rag.knowledge_bases AS knowledge_base
|
||||
ON knowledge_base.id = chunk.knowledge_base_id
|
||||
AND knowledge_base.active_embedding_profile_hash = %s
|
||||
WHERE chunk.searchable
|
||||
AND chunk.knowledge_base_id = %s
|
||||
AND chunk.access_scope_id = %s
|
||||
AND chunk.embedding_profile_hash = %s
|
||||
ORDER BY chunk.embedding <=> %s
|
||||
LIMIT %s
|
||||
""",
|
||||
(
|
||||
Vector(list(query_vector)),
|
||||
KNOWLEDGE_BASE_ID,
|
||||
ACCESS_SCOPE_ID,
|
||||
profile_hash,
|
||||
namespace.knowledge_base_id,
|
||||
namespace.access_scope_id,
|
||||
profile_hash,
|
||||
Vector(list(query_vector)),
|
||||
settings.vector_top_k,
|
||||
),
|
||||
@@ -502,12 +700,20 @@ async def evaluate_queries(
|
||||
queries: Sequence[DemoQuery],
|
||||
embedder: EmbeddingProvider,
|
||||
reranker: Reranker,
|
||||
*,
|
||||
namespace: DemoNamespace,
|
||||
profile_hash: str,
|
||||
) -> dict[str, float | int]:
|
||||
hits = 0
|
||||
answerable = 0
|
||||
for query in queries:
|
||||
query_result = await embedder.embed_query(query.query)
|
||||
candidates = retrieve(settings, query_result.vectors[0])
|
||||
candidates = retrieve(
|
||||
settings,
|
||||
query_result.vectors[0],
|
||||
namespace=namespace,
|
||||
profile_hash=profile_hash,
|
||||
)
|
||||
if not candidates:
|
||||
continue
|
||||
reranked = await reranker.rerank(
|
||||
@@ -552,14 +758,14 @@ async def async_main() -> int:
|
||||
return 2
|
||||
|
||||
settings = Settings()
|
||||
namespace = namespace_for_mode(mode)
|
||||
documents_path = Path(
|
||||
os.getenv("DEMO_DOCUMENTS_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_documents.jsonl"))
|
||||
)
|
||||
queries_path = Path(
|
||||
os.getenv("DEMO_QUERIES_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_queries.jsonl"))
|
||||
)
|
||||
cloud_embedder: BailianEmbeddingAdapter | None = None
|
||||
cloud_reranker: BailianRerankerAdapter | None = None
|
||||
cloud_gateway: ModelGatewayAdapter | None = None
|
||||
try:
|
||||
documents = load_documents(documents_path)
|
||||
queries = load_queries(queries_path)
|
||||
@@ -567,24 +773,9 @@ async def async_main() -> int:
|
||||
embedder: EmbeddingProvider
|
||||
reranker: Reranker
|
||||
if mode == "bailian":
|
||||
api_key = settings.bailian_api_key()
|
||||
cloud_embedder = BailianEmbeddingAdapter(
|
||||
api_key=api_key,
|
||||
base_url=settings.bailian_openai_base_url,
|
||||
model=settings.embedding_model,
|
||||
dimensions=settings.embedding_dimension,
|
||||
timeout_seconds=settings.model_timeout_seconds,
|
||||
max_retries=settings.model_max_retries,
|
||||
)
|
||||
cloud_reranker = BailianRerankerAdapter(
|
||||
api_key=api_key,
|
||||
base_url=settings.bailian_rerank_base_url,
|
||||
model=settings.rerank_model,
|
||||
timeout_seconds=settings.model_timeout_seconds,
|
||||
max_retries=settings.model_max_retries,
|
||||
)
|
||||
embedder = cloud_embedder
|
||||
reranker = cloud_reranker
|
||||
cloud_gateway = ModelGatewayAdapter.from_settings(settings)
|
||||
embedder = cloud_gateway
|
||||
reranker = cloud_gateway
|
||||
else:
|
||||
embedder = FakeEmbeddingProvider(settings.embedding_dimension)
|
||||
reranker = FakeReranker()
|
||||
@@ -599,9 +790,17 @@ async def async_main() -> int:
|
||||
vectors,
|
||||
profile_hash=profile_hash,
|
||||
embedding_model=resolved_model,
|
||||
namespace=namespace,
|
||||
)
|
||||
counts = write_chunks(settings, prepared, namespace=namespace)
|
||||
metrics = await evaluate_queries(
|
||||
settings,
|
||||
queries,
|
||||
embedder,
|
||||
reranker,
|
||||
namespace=namespace,
|
||||
profile_hash=profile_hash,
|
||||
)
|
||||
counts = write_chunks(settings, prepared)
|
||||
metrics = await evaluate_queries(settings, queries, embedder, reranker)
|
||||
output_summary(
|
||||
{
|
||||
"counts": counts,
|
||||
@@ -654,10 +853,8 @@ async def async_main() -> int:
|
||||
)
|
||||
return 1
|
||||
finally:
|
||||
if cloud_embedder is not None:
|
||||
await cloud_embedder.aclose()
|
||||
if cloud_reranker is not None:
|
||||
await cloud_reranker.aclose()
|
||||
if cloud_gateway is not None:
|
||||
await cloud_gateway.aclose()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Add governed model profiles, embedding cache, and invocation metadata.
|
||||
|
||||
Revision ID: 0002_model_profiles
|
||||
Revises: 0001_initial_schema
|
||||
Create Date: 2026-07-13
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002_model_profiles"
|
||||
down_revision: str | None = "0001_initial_schema"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE rag.model_profiles (
|
||||
profile_hash char(64) PRIMARY KEY,
|
||||
alias text NOT NULL,
|
||||
kind text NOT NULL,
|
||||
provider text NOT NULL,
|
||||
model text NOT NULL,
|
||||
api_mode text NOT NULL,
|
||||
dimension smallint,
|
||||
endpoint_identity_hash char(64) NOT NULL,
|
||||
config_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
synthetic boolean NOT NULL DEFAULT false,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT model_profiles_alias_key UNIQUE (alias),
|
||||
CONSTRAINT model_profiles_hash_kind_key UNIQUE (profile_hash, kind),
|
||||
CONSTRAINT model_profiles_hash_format
|
||||
CHECK (profile_hash ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT model_profiles_alias_nonempty
|
||||
CHECK (btrim(alias) <> ''),
|
||||
CONSTRAINT model_profiles_kind_valid
|
||||
CHECK (kind IN ('embedding', 'rerank', 'chat')),
|
||||
CONSTRAINT model_profiles_identity_nonempty
|
||||
CHECK (
|
||||
btrim(provider) <> ''
|
||||
AND btrim(model) <> ''
|
||||
AND btrim(api_mode) <> ''
|
||||
),
|
||||
CONSTRAINT model_profiles_embedding_dimension
|
||||
CHECK (
|
||||
(kind = 'embedding' AND dimension = 1024)
|
||||
OR (kind IN ('rerank', 'chat') AND dimension IS NULL)
|
||||
),
|
||||
CONSTRAINT model_profiles_endpoint_identity_hash_format
|
||||
CHECK (endpoint_identity_hash ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT model_profiles_config_snapshot_object
|
||||
CHECK (jsonb_typeof(config_snapshot) = 'object'),
|
||||
CONSTRAINT model_profiles_config_snapshot_has_no_credentials
|
||||
CHECK (
|
||||
config_snapshot::text !~*
|
||||
'"[^\"]*(api[_-]?key|secret|password|token|authorization|credential)[^\"]*"[[:space:]]*:'
|
||||
),
|
||||
CONSTRAINT model_profiles_timestamps_valid
|
||||
CHECK (updated_at >= created_at)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE rag.knowledge_bases
|
||||
ADD COLUMN active_embedding_profile_hash char(64),
|
||||
ADD COLUMN active_embedding_profile_kind text NOT NULL DEFAULT 'embedding',
|
||||
ADD CONSTRAINT knowledge_bases_active_embedding_profile_hash_format
|
||||
CHECK (
|
||||
active_embedding_profile_hash IS NULL
|
||||
OR active_embedding_profile_hash ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
ADD CONSTRAINT knowledge_bases_active_embedding_profile_fk
|
||||
FOREIGN KEY (
|
||||
active_embedding_profile_hash,
|
||||
active_embedding_profile_kind
|
||||
)
|
||||
REFERENCES rag.model_profiles (profile_hash, kind)
|
||||
ON DELETE RESTRICT;
|
||||
ALTER TABLE rag.knowledge_bases
|
||||
ADD CONSTRAINT knowledge_bases_active_embedding_profile_kind
|
||||
CHECK (active_embedding_profile_kind = 'embedding');
|
||||
"""
|
||||
)
|
||||
|
||||
# The only profile that can be inferred safely from legacy rows is an explicitly
|
||||
# synthetic, searchable fake embedding profile with one unambiguous model name.
|
||||
# Live provider identity is never guessed from model names or endpoint values.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO rag.model_profiles (
|
||||
profile_hash,
|
||||
alias,
|
||||
kind,
|
||||
provider,
|
||||
model,
|
||||
api_mode,
|
||||
dimension,
|
||||
endpoint_identity_hash,
|
||||
config_snapshot,
|
||||
synthetic,
|
||||
enabled
|
||||
)
|
||||
SELECT
|
||||
chunk.embedding_profile_hash,
|
||||
'fake-embedding-' || left(chunk.embedding_profile_hash, 12),
|
||||
'embedding',
|
||||
'local-synthetic',
|
||||
min(chunk.embedding_model),
|
||||
'deterministic-offline',
|
||||
1024,
|
||||
encode(sha256(convert_to('local-fake', 'UTF8')), 'hex'),
|
||||
jsonb_build_object(
|
||||
'migration_revision', '0002_model_profiles',
|
||||
'source', 'existing_searchable_fake_chunks'
|
||||
),
|
||||
true,
|
||||
true
|
||||
FROM rag.chunks AS chunk
|
||||
WHERE chunk.searchable IS TRUE
|
||||
AND chunk.embedding_profile_hash ~ '^[0-9a-f]{64}$'
|
||||
AND chunk.embedding_dimension = 1024
|
||||
AND lower(chunk.embedding_model) LIKE 'fake-%'
|
||||
GROUP BY chunk.embedding_profile_hash
|
||||
HAVING count(DISTINCT chunk.embedding_model) = 1
|
||||
ON CONFLICT (profile_hash) DO NOTHING;
|
||||
"""
|
||||
)
|
||||
|
||||
# A knowledge base is activated only when its searchable legacy projection has
|
||||
# exactly one backfilled fake profile. Multiple profiles intentionally leave NULL.
|
||||
op.execute(
|
||||
"""
|
||||
WITH unique_searchable_fake_profile AS (
|
||||
SELECT
|
||||
chunk.knowledge_base_id,
|
||||
min(chunk.embedding_profile_hash) AS profile_hash
|
||||
FROM rag.chunks AS chunk
|
||||
JOIN rag.model_profiles AS profile
|
||||
ON profile.profile_hash = chunk.embedding_profile_hash
|
||||
AND profile.kind = 'embedding'
|
||||
AND profile.synthetic IS TRUE
|
||||
WHERE chunk.searchable IS TRUE
|
||||
AND lower(chunk.embedding_model) LIKE 'fake-%'
|
||||
GROUP BY chunk.knowledge_base_id
|
||||
HAVING count(DISTINCT chunk.embedding_profile_hash) = 1
|
||||
)
|
||||
UPDATE rag.knowledge_bases AS knowledge_base
|
||||
SET active_embedding_profile_hash = candidate.profile_hash,
|
||||
updated_at = now()
|
||||
FROM unique_searchable_fake_profile AS candidate
|
||||
WHERE knowledge_base.id = candidate.knowledge_base_id
|
||||
AND knowledge_base.active_embedding_profile_hash IS NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE rag.chunks
|
||||
ADD COLUMN citation_id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
ADD CONSTRAINT chunks_citation_id_key UNIQUE (citation_id),
|
||||
ADD CONSTRAINT chunks_id_embedding_text_sha256_key
|
||||
UNIQUE (id, embedding_text_sha256);
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE rag.embedding_cache (
|
||||
profile_hash char(64) NOT NULL,
|
||||
profile_kind text NOT NULL DEFAULT 'embedding',
|
||||
embedding_text_sha256 char(64) NOT NULL,
|
||||
embedding vector(1024) NOT NULL,
|
||||
resolved_model text NOT NULL,
|
||||
provider_request_id text,
|
||||
usage jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
elapsed_ms integer NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT embedding_cache_primary_key
|
||||
PRIMARY KEY (profile_hash, embedding_text_sha256),
|
||||
CONSTRAINT embedding_cache_profile_fk
|
||||
FOREIGN KEY (profile_hash, profile_kind)
|
||||
REFERENCES rag.model_profiles (profile_hash, kind)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT embedding_cache_profile_kind
|
||||
CHECK (profile_kind = 'embedding'),
|
||||
CONSTRAINT embedding_cache_text_hash_format
|
||||
CHECK (embedding_text_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT embedding_cache_vector_dimension
|
||||
CHECK (vector_dims(embedding) = 1024),
|
||||
CONSTRAINT embedding_cache_resolved_model_nonempty
|
||||
CHECK (btrim(resolved_model) <> ''),
|
||||
CONSTRAINT embedding_cache_request_id_valid
|
||||
CHECK (
|
||||
provider_request_id IS NULL
|
||||
OR (
|
||||
btrim(provider_request_id) <> ''
|
||||
AND length(provider_request_id) <= 512
|
||||
)
|
||||
),
|
||||
CONSTRAINT embedding_cache_usage_object
|
||||
CHECK (jsonb_typeof(usage) = 'object'),
|
||||
CONSTRAINT embedding_cache_elapsed_valid
|
||||
CHECK (elapsed_ms >= 0),
|
||||
CONSTRAINT embedding_cache_timestamps_valid
|
||||
CHECK (updated_at >= created_at)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE rag.chunk_embedding_assignments (
|
||||
chunk_id uuid NOT NULL,
|
||||
profile_hash char(64) NOT NULL,
|
||||
profile_kind text NOT NULL DEFAULT 'embedding',
|
||||
embedding_text_sha256 char(64) NOT NULL,
|
||||
cache_profile_hash char(64),
|
||||
cache_embedding_text_sha256 char(64),
|
||||
status text NOT NULL DEFAULT 'PENDING',
|
||||
error_code text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz,
|
||||
CONSTRAINT chunk_embedding_assignments_primary_key
|
||||
PRIMARY KEY (chunk_id, profile_hash),
|
||||
CONSTRAINT chunk_embedding_assignments_chunk_text_fk
|
||||
FOREIGN KEY (chunk_id, embedding_text_sha256)
|
||||
REFERENCES rag.chunks (id, embedding_text_sha256)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT chunk_embedding_assignments_profile_fk
|
||||
FOREIGN KEY (profile_hash, profile_kind)
|
||||
REFERENCES rag.model_profiles (profile_hash, kind)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT chunk_embedding_assignments_profile_kind
|
||||
CHECK (profile_kind = 'embedding'),
|
||||
CONSTRAINT chunk_embedding_assignments_cache_fk
|
||||
FOREIGN KEY (cache_profile_hash, cache_embedding_text_sha256)
|
||||
REFERENCES rag.embedding_cache (profile_hash, embedding_text_sha256)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT chunk_embedding_assignments_text_hash_format
|
||||
CHECK (embedding_text_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT chunk_embedding_assignments_status_valid
|
||||
CHECK (status IN ('PENDING', 'EMBEDDING', 'READY', 'FAILED', 'STALE')),
|
||||
CONSTRAINT chunk_embedding_assignments_cache_binding
|
||||
CHECK (
|
||||
(
|
||||
status = 'READY'
|
||||
AND cache_profile_hash = profile_hash
|
||||
AND cache_embedding_text_sha256 = embedding_text_sha256
|
||||
)
|
||||
OR (
|
||||
status <> 'READY'
|
||||
AND cache_profile_hash IS NULL
|
||||
AND cache_embedding_text_sha256 IS NULL
|
||||
)
|
||||
),
|
||||
CONSTRAINT chunk_embedding_assignments_completion_consistent
|
||||
CHECK (
|
||||
(
|
||||
status IN ('READY', 'FAILED', 'STALE')
|
||||
AND completed_at IS NOT NULL
|
||||
)
|
||||
OR (
|
||||
status IN ('PENDING', 'EMBEDDING')
|
||||
AND completed_at IS NULL
|
||||
)
|
||||
),
|
||||
CONSTRAINT chunk_embedding_assignments_error_code_valid
|
||||
CHECK (
|
||||
error_code IS NULL
|
||||
OR (btrim(error_code) <> '' AND length(error_code) <= 128)
|
||||
),
|
||||
CONSTRAINT chunk_embedding_assignments_timestamps_valid
|
||||
CHECK (
|
||||
updated_at >= created_at
|
||||
AND (completed_at IS NULL OR completed_at >= created_at)
|
||||
)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX chunk_embedding_assignments_work_queue
|
||||
ON rag.chunk_embedding_assignments (profile_hash, status, updated_at)
|
||||
WHERE status IN ('PENDING', 'EMBEDDING');
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE rag.model_invocations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
trace_id uuid NOT NULL,
|
||||
caller text NOT NULL,
|
||||
operation text NOT NULL,
|
||||
profile_hash char(64) NOT NULL,
|
||||
model text NOT NULL,
|
||||
provider_request_id text,
|
||||
status text NOT NULL,
|
||||
item_count integer NOT NULL DEFAULT 0,
|
||||
prompt_tokens integer NOT NULL DEFAULT 0,
|
||||
completion_tokens integer NOT NULL DEFAULT 0,
|
||||
total_tokens integer NOT NULL DEFAULT 0,
|
||||
elapsed_ms integer,
|
||||
error_code text,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT model_invocations_profile_fk
|
||||
FOREIGN KEY (profile_hash, operation)
|
||||
REFERENCES rag.model_profiles (profile_hash, kind)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT model_invocations_caller_nonempty
|
||||
CHECK (btrim(caller) <> ''),
|
||||
CONSTRAINT model_invocations_operation_valid
|
||||
CHECK (operation IN ('embedding', 'rerank', 'chat')),
|
||||
CONSTRAINT model_invocations_model_nonempty
|
||||
CHECK (btrim(model) <> ''),
|
||||
CONSTRAINT model_invocations_request_id_valid
|
||||
CHECK (
|
||||
provider_request_id IS NULL
|
||||
OR (
|
||||
btrim(provider_request_id) <> ''
|
||||
AND length(provider_request_id) <= 512
|
||||
)
|
||||
),
|
||||
CONSTRAINT model_invocations_status_valid
|
||||
CHECK (status IN ('STARTED', 'SUCCEEDED', 'FAILED', 'UNKNOWN')),
|
||||
CONSTRAINT model_invocations_counts_valid
|
||||
CHECK (
|
||||
item_count >= 0
|
||||
AND prompt_tokens >= 0
|
||||
AND completion_tokens >= 0
|
||||
AND total_tokens >= 0
|
||||
AND total_tokens = prompt_tokens + completion_tokens
|
||||
),
|
||||
CONSTRAINT model_invocations_elapsed_valid
|
||||
CHECK (
|
||||
(status = 'STARTED' AND elapsed_ms IS NULL)
|
||||
OR (status <> 'STARTED' AND elapsed_ms >= 0)
|
||||
),
|
||||
CONSTRAINT model_invocations_error_code_valid
|
||||
CHECK (
|
||||
error_code IS NULL
|
||||
OR (btrim(error_code) <> '' AND length(error_code) <= 128)
|
||||
),
|
||||
CONSTRAINT model_invocations_error_consistent
|
||||
CHECK (
|
||||
(status = 'SUCCEEDED' AND error_code IS NULL)
|
||||
OR (status = 'FAILED' AND error_code IS NOT NULL)
|
||||
OR (status = 'UNKNOWN' AND error_code IS NOT NULL)
|
||||
OR (status = 'STARTED' AND error_code IS NULL)
|
||||
),
|
||||
CONSTRAINT model_invocations_timestamps_valid
|
||||
CHECK (
|
||||
created_at >= started_at
|
||||
AND (
|
||||
(status = 'STARTED' AND finished_at IS NULL)
|
||||
OR (status <> 'STARTED' AND finished_at >= started_at)
|
||||
)
|
||||
)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
COMMENT ON TABLE rag.model_invocations IS
|
||||
'Metadata-only provider audit log. Provider inputs and outputs are forbidden.';
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX model_invocations_trace_lookup
|
||||
ON rag.model_invocations (trace_id, started_at DESC);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX model_invocations_profile_status_lookup
|
||||
ON rag.model_invocations (profile_hash, operation, status, started_at DESC);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX chunks_active_embedding_profile_filter
|
||||
ON rag.chunks (
|
||||
knowledge_base_id,
|
||||
embedding_profile_hash,
|
||||
access_scope_id
|
||||
)
|
||||
WHERE searchable;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS rag.chunks_active_embedding_profile_filter;")
|
||||
op.execute("DROP TABLE IF EXISTS rag.model_invocations;")
|
||||
op.execute("DROP TABLE IF EXISTS rag.chunk_embedding_assignments;")
|
||||
op.execute("DROP TABLE IF EXISTS rag.embedding_cache;")
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE rag.chunks
|
||||
DROP CONSTRAINT IF EXISTS chunks_id_embedding_text_sha256_key,
|
||||
DROP CONSTRAINT IF EXISTS chunks_citation_id_key,
|
||||
DROP COLUMN IF EXISTS citation_id;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE rag.knowledge_bases
|
||||
DROP CONSTRAINT IF EXISTS knowledge_bases_active_embedding_profile_fk,
|
||||
DROP CONSTRAINT IF EXISTS knowledge_bases_active_embedding_profile_hash_format,
|
||||
DROP CONSTRAINT IF EXISTS knowledge_bases_active_embedding_profile_kind,
|
||||
DROP COLUMN IF EXISTS active_embedding_profile_kind,
|
||||
DROP COLUMN IF EXISTS active_embedding_profile_hash;
|
||||
"""
|
||||
)
|
||||
op.execute("DROP TABLE IF EXISTS rag.model_profiles;")
|
||||
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MIGRATION_PATH = ROOT / "backend/migrations/versions/0002_model_profiles_and_invocations.py"
|
||||
MIGRATION = MIGRATION_PATH.read_text(encoding="utf-8")
|
||||
NORMALIZED = " ".join(MIGRATION.lower().split())
|
||||
|
||||
|
||||
def _table_definition(name: str) -> str:
|
||||
pattern = re.compile(rf"(?ms)create table rag\.{re.escape(name)} \((.*?)^ \);")
|
||||
match = pattern.search(MIGRATION.lower())
|
||||
assert match is not None, f"missing table definition: rag.{name}"
|
||||
return " ".join(match.group(1).split())
|
||||
|
||||
|
||||
def test_revision_is_additive_after_initial_schema() -> None:
|
||||
assert 'revision: str = "0002_model_profiles"' in MIGRATION
|
||||
assert 'down_revision: str | none = "0001_initial_schema"' in MIGRATION.lower()
|
||||
revision_match = re.search(r'^revision: str = "([^"]+)"$', MIGRATION, re.MULTILINE)
|
||||
assert revision_match is not None
|
||||
assert len(revision_match.group(1)) <= 32
|
||||
assert "alter table rag.chunks" in NORMALIZED
|
||||
assert "alter table rag.knowledge_bases" in NORMALIZED
|
||||
assert "drop table if exists rag.chunks" not in NORMALIZED
|
||||
assert "drop table if exists rag.knowledge_bases" not in NORMALIZED
|
||||
|
||||
|
||||
def test_model_profiles_have_governed_identity_and_dimension_contract() -> None:
|
||||
table = _table_definition("model_profiles")
|
||||
|
||||
for column in (
|
||||
"profile_hash char(64) primary key",
|
||||
"alias text not null",
|
||||
"kind text not null",
|
||||
"provider text not null",
|
||||
"model text not null",
|
||||
"api_mode text not null",
|
||||
"dimension smallint",
|
||||
"endpoint_identity_hash char(64) not null",
|
||||
"config_snapshot jsonb not null default '{}'::jsonb",
|
||||
"synthetic boolean not null default false",
|
||||
"enabled boolean not null default true",
|
||||
"created_at timestamptz not null default now()",
|
||||
"updated_at timestamptz not null default now()",
|
||||
):
|
||||
assert column in table
|
||||
|
||||
assert "model_profiles_alias_key unique (alias)" in table
|
||||
assert "model_profiles_hash_kind_key unique (profile_hash, kind)" in table
|
||||
assert "kind in ('embedding', 'rerank', 'chat')" in table
|
||||
assert "kind = 'embedding' and dimension = 1024" in table
|
||||
assert "kind in ('rerank', 'chat') and dimension is null" in table
|
||||
assert "profile_hash ~ '^[0-9a-f]{64}$'" in table
|
||||
assert "endpoint_identity_hash ~ '^[0-9a-f]{64}$'" in table
|
||||
assert "jsonb_typeof(config_snapshot) = 'object'" in table
|
||||
assert "model_profiles_config_snapshot_has_no_credentials" in table
|
||||
for credential_name in (
|
||||
"api[_-]?key",
|
||||
"secret",
|
||||
"password",
|
||||
"token",
|
||||
"authorization",
|
||||
"credential",
|
||||
):
|
||||
assert credential_name in table
|
||||
|
||||
|
||||
def test_knowledge_base_active_profile_is_nullable_and_restrictive() -> None:
|
||||
assert "add column active_embedding_profile_hash char(64)" in NORMALIZED
|
||||
assert (
|
||||
"add column active_embedding_profile_kind text not null default 'embedding'" in NORMALIZED
|
||||
)
|
||||
assert "knowledge_bases_active_embedding_profile_hash_format" in NORMALIZED
|
||||
assert "knowledge_bases_active_embedding_profile_kind" in NORMALIZED
|
||||
assert "check (active_embedding_profile_kind = 'embedding')" in NORMALIZED
|
||||
assert "knowledge_bases_active_embedding_profile_fk" in NORMALIZED
|
||||
assert (
|
||||
"foreign key ( active_embedding_profile_hash, active_embedding_profile_kind )" in NORMALIZED
|
||||
)
|
||||
assert "references rag.model_profiles (profile_hash, kind) on delete restrict" in NORMALIZED
|
||||
assert "active_embedding_profile_hash is null" in NORMALIZED
|
||||
|
||||
|
||||
def test_legacy_backfill_only_activates_one_unambiguous_searchable_fake_profile() -> None:
|
||||
assert "insert into rag.model_profiles" in NORMALIZED
|
||||
assert "chunk.searchable is true" in NORMALIZED
|
||||
assert "lower(chunk.embedding_model) like 'fake-%'" in NORMALIZED
|
||||
assert "chunk.embedding_dimension = 1024" in NORMALIZED
|
||||
assert "having count(distinct chunk.embedding_model) = 1" in NORMALIZED
|
||||
assert "'local-synthetic'" in NORMALIZED
|
||||
assert "'deterministic-offline'" in NORMALIZED
|
||||
assert "sha256(convert_to('local-fake', 'utf8'))" in NORMALIZED
|
||||
assert "'existing_searchable_fake_chunks'" in NORMALIZED
|
||||
assert "on conflict (profile_hash) do nothing" in NORMALIZED
|
||||
|
||||
activation_start = NORMALIZED.index("with unique_searchable_fake_profile as")
|
||||
activation_end = NORMALIZED.index(") update rag.knowledge_bases", activation_start)
|
||||
candidate_query = NORMALIZED[activation_start:activation_end]
|
||||
assert "group by chunk.knowledge_base_id" in candidate_query
|
||||
assert "having count(distinct chunk.embedding_profile_hash) = 1" in candidate_query
|
||||
assert "limit 1" not in candidate_query
|
||||
assert "active_embedding_profile_hash is null" in NORMALIZED[activation_start:]
|
||||
|
||||
|
||||
def test_embedding_cache_is_profile_and_exact_text_keyed() -> None:
|
||||
table = _table_definition("embedding_cache")
|
||||
|
||||
assert "profile_hash char(64) not null" in table
|
||||
assert "profile_kind text not null default 'embedding'" in table
|
||||
assert "embedding_text_sha256 char(64) not null" in table
|
||||
assert "embedding vector(1024) not null" in table
|
||||
assert "resolved_model text not null" in table
|
||||
assert "provider_request_id text" in table
|
||||
assert "usage jsonb not null default '{}'::jsonb" in table
|
||||
assert "elapsed_ms integer not null" in table
|
||||
assert "primary key (profile_hash, embedding_text_sha256)" in table
|
||||
assert "references rag.model_profiles (profile_hash, kind) on delete restrict" in table
|
||||
assert "profile_kind = 'embedding'" in table
|
||||
assert "vector_dims(embedding) = 1024" in table
|
||||
assert "jsonb_typeof(usage) = 'object'" in table
|
||||
|
||||
|
||||
def test_chunk_assignments_bind_chunk_text_profile_cache_and_state() -> None:
|
||||
table = _table_definition("chunk_embedding_assignments")
|
||||
|
||||
assert "primary key (chunk_id, profile_hash)" in table
|
||||
assert "profile_kind text not null default 'embedding'" in table
|
||||
assert "foreign key (chunk_id, embedding_text_sha256)" in table
|
||||
assert "references rag.chunks (id, embedding_text_sha256) on delete cascade" in table
|
||||
assert "foreign key (profile_hash, profile_kind)" in table
|
||||
assert "references rag.model_profiles (profile_hash, kind) on delete restrict" in table
|
||||
assert "profile_kind = 'embedding'" in table
|
||||
assert "foreign key (cache_profile_hash, cache_embedding_text_sha256)" in table
|
||||
assert "references rag.embedding_cache (profile_hash, embedding_text_sha256)" in table
|
||||
assert "status in ('pending', 'embedding', 'ready', 'failed', 'stale')" in table
|
||||
assert "status = 'ready' and cache_profile_hash = profile_hash" in table
|
||||
assert "cache_embedding_text_sha256 = embedding_text_sha256" in table
|
||||
assert "status <> 'ready' and cache_profile_hash is null" in table
|
||||
assert "chunks_id_embedding_text_sha256_key" in NORMALIZED
|
||||
|
||||
|
||||
def test_invocation_audit_table_is_metadata_only() -> None:
|
||||
table = _table_definition("model_invocations")
|
||||
|
||||
for field in (
|
||||
"trace_id uuid not null",
|
||||
"caller text not null",
|
||||
"operation text not null",
|
||||
"profile_hash char(64) not null",
|
||||
"model text not null",
|
||||
"provider_request_id text",
|
||||
"status text not null",
|
||||
"item_count integer not null default 0",
|
||||
"prompt_tokens integer not null default 0",
|
||||
"completion_tokens integer not null default 0",
|
||||
"total_tokens integer not null default 0",
|
||||
"elapsed_ms integer",
|
||||
"error_code text",
|
||||
"started_at timestamptz not null default now()",
|
||||
"finished_at timestamptz",
|
||||
"created_at timestamptz not null default now()",
|
||||
):
|
||||
assert field in table
|
||||
|
||||
for forbidden_field in (
|
||||
"api_key",
|
||||
"secret",
|
||||
"authorization",
|
||||
"credential",
|
||||
"endpoint",
|
||||
"url",
|
||||
"payload",
|
||||
"request_body",
|
||||
"response_body",
|
||||
"prompt_text",
|
||||
"query_text",
|
||||
"input_text",
|
||||
"output_text",
|
||||
"content",
|
||||
):
|
||||
assert forbidden_field not in table
|
||||
|
||||
assert "total_tokens = prompt_tokens + completion_tokens" in table
|
||||
assert "foreign key (profile_hash, operation)" in table
|
||||
assert "references rag.model_profiles (profile_hash, kind)" in table
|
||||
assert "status = 'succeeded' and error_code is null" in table
|
||||
assert "status = 'failed' and error_code is not null" in table
|
||||
assert "status = 'unknown' and error_code is not null" in table
|
||||
assert "status = 'started' and elapsed_ms is null" in table
|
||||
assert "status = 'started' and finished_at is null" in table
|
||||
|
||||
|
||||
def test_chunks_get_stable_citations_and_active_profile_filter_index() -> None:
|
||||
assert "add column citation_id uuid not null default gen_random_uuid()" in NORMALIZED
|
||||
assert "chunks_citation_id_key unique (citation_id)" in NORMALIZED
|
||||
assert "create index chunks_active_embedding_profile_filter" in NORMALIZED
|
||||
assert (
|
||||
"on rag.chunks ( knowledge_base_id, embedding_profile_hash, access_scope_id ) "
|
||||
"where searchable"
|
||||
) in NORMALIZED
|
||||
|
||||
|
||||
def test_downgrade_removes_dependents_before_profiles_and_added_columns() -> None:
|
||||
invocation_drop = NORMALIZED.index("drop table if exists rag.model_invocations")
|
||||
assignment_drop = NORMALIZED.index("drop table if exists rag.chunk_embedding_assignments")
|
||||
cache_drop = NORMALIZED.index("drop table if exists rag.embedding_cache")
|
||||
chunk_binding_drop = NORMALIZED.index(
|
||||
"drop constraint if exists chunks_id_embedding_text_sha256_key"
|
||||
)
|
||||
knowledge_base_fk_drop = NORMALIZED.index(
|
||||
"drop constraint if exists knowledge_bases_active_embedding_profile_fk"
|
||||
)
|
||||
profile_drop = NORMALIZED.index("drop table if exists rag.model_profiles")
|
||||
|
||||
assert invocation_drop < profile_drop
|
||||
assert assignment_drop < cache_drop < chunk_binding_drop < profile_drop
|
||||
assert knowledge_base_fk_drop < profile_drop
|
||||
assert "drop column if exists citation_id" in NORMALIZED
|
||||
assert "drop column if exists active_embedding_profile_hash" in NORMALIZED
|
||||
assert "drop column if exists active_embedding_profile_kind" in NORMALIZED
|
||||
@@ -36,6 +36,7 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
|
||||
db = _service_block("db")
|
||||
migrate = _service_block("migrate")
|
||||
api = _service_block("api")
|
||||
model_gateway = _service_block("model-gateway")
|
||||
gateway = _service_block("gateway")
|
||||
web = _service_block("web")
|
||||
provider_smoke = _service_block("provider-smoke")
|
||||
@@ -51,20 +52,37 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
|
||||
assert "postgres_app_password" not in migrate
|
||||
|
||||
assert "postgres_app_password" in api
|
||||
assert "model_gateway_api_token" in api
|
||||
assert "postgres_bootstrap_password" not in api
|
||||
assert "postgres_migrator_password" not in api
|
||||
assert "bailian_api_key" not in api
|
||||
assert '"127.0.0.1:8000:8000"' not in api
|
||||
assert " - data" in api
|
||||
assert " - model" in api
|
||||
assert " - edge" not in api
|
||||
assert " - egress" not in api
|
||||
assert "read_only: true" in api
|
||||
assert "no-new-privileges:true" in api
|
||||
assert "cap_drop:" in api and " - ALL" in api
|
||||
|
||||
assert "bailian_api_key" in model_gateway
|
||||
assert "model_gateway_api_token" in model_gateway
|
||||
assert "model_gateway_worker_token" in model_gateway
|
||||
assert "postgres_" not in model_gateway
|
||||
assert " - model" in model_gateway
|
||||
assert " - egress" in model_gateway
|
||||
assert " - data" not in model_gateway
|
||||
assert " - edge" not in model_gateway
|
||||
assert " - ingress" not in model_gateway
|
||||
assert "ports:" not in model_gateway
|
||||
assert "read_only: true" in model_gateway
|
||||
assert "no-new-privileges:true" in model_gateway
|
||||
assert "cap_drop:" in model_gateway and " - ALL" in model_gateway
|
||||
|
||||
assert '"127.0.0.1:8000:8000"' not in gateway
|
||||
assert " - ingress" in gateway
|
||||
assert " - data" in gateway
|
||||
assert " - model" not in gateway
|
||||
assert " - edge" not in gateway
|
||||
assert " - egress" not in gateway
|
||||
assert "secrets:" not in gateway
|
||||
@@ -77,6 +95,7 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
|
||||
assert " - edge" in web
|
||||
assert " - ingress" in web
|
||||
assert " - data" not in web
|
||||
assert " - model" not in web
|
||||
assert " - egress" not in web
|
||||
assert "secrets:" not in web
|
||||
assert "POSTGRES_" not in web
|
||||
@@ -85,13 +104,20 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
|
||||
assert "no-new-privileges:true" in web
|
||||
assert len(re.findall(r"(?m)^ ports:$", COMPOSE)) == 1
|
||||
|
||||
assert "bailian_api_key" in provider_smoke
|
||||
assert "bailian_api_key" not in provider_smoke
|
||||
assert "model_gateway_api_token" in provider_smoke
|
||||
assert "postgres_" not in provider_smoke
|
||||
assert " - model" in provider_smoke
|
||||
assert " - egress" not in provider_smoke
|
||||
|
||||
assert "postgres_app_password" in seed_demo
|
||||
assert "postgres_bootstrap_password" not in seed_demo
|
||||
assert "postgres_migrator_password" not in seed_demo
|
||||
assert "bailian_api_key" in seed_demo
|
||||
assert "bailian_api_key" not in seed_demo
|
||||
assert "model_gateway_worker_token" in seed_demo
|
||||
assert "MODEL_GATEWAY_CALLER: worker" in seed_demo
|
||||
assert " - model" in seed_demo
|
||||
assert " - egress" not in seed_demo
|
||||
assert "./data/samples/public:/demo:ro" in seed_demo
|
||||
|
||||
assert "postgres_app_password" in seed_demo_offline
|
||||
@@ -101,6 +127,7 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
|
||||
|
||||
assert re.search(r"(?ms)^ data:\n.*?^ internal: true$", COMPOSE)
|
||||
assert re.search(r"(?ms)^ ingress:\n.*?^ internal: true$", COMPOSE)
|
||||
assert re.search(r"(?ms)^ model:\n.*?^ internal: true$", COMPOSE)
|
||||
assert re.search(r"(?m)^ edge:$", COMPOSE)
|
||||
assert re.search(r"(?m)^ egress:$", COMPOSE)
|
||||
|
||||
|
||||
65
backend/tests/unit/test_application_contract.py
Normal file
65
backend/tests/unit/test_application_contract.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.problems import ApiProblem
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_application_factory_generates_openapi_without_runtime_secrets() -> None:
|
||||
app = create_app()
|
||||
schema = app.openapi()
|
||||
|
||||
assert schema["openapi"].startswith("3.")
|
||||
assert "/api/v1/health/live" in schema["paths"]
|
||||
assert "/api/v1/meta" in schema["paths"]
|
||||
assert "/health/live" not in schema["paths"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_id_accepts_only_uuid_and_is_returned() -> None:
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
supplied = str(uuid.uuid4())
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
accepted = await client.get("/api/v1/health/live", headers={"x-request-id": supplied})
|
||||
replaced = await client.get(
|
||||
"/api/v1/health/live",
|
||||
headers={"x-request-id": "secret-or-unbounded-client-value"},
|
||||
)
|
||||
|
||||
assert accepted.headers["x-request-id"] == supplied
|
||||
assert uuid.UUID(replaced.headers["x-request-id"])
|
||||
assert replaced.headers["x-request-id"] != "secret-or-unbounded-client-value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_formal_api_problem_is_sanitized_and_traceable() -> None:
|
||||
app = create_app()
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/v1/problem-test")
|
||||
async def fail() -> None:
|
||||
raise ApiProblem(
|
||||
status=409,
|
||||
code="VERSION_CONFLICT",
|
||||
title="Version conflict",
|
||||
detail="The resource changed; reload and retry.",
|
||||
)
|
||||
|
||||
app.include_router(router)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/problem-test")
|
||||
|
||||
payload = response.json()
|
||||
assert response.status_code == 409
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert payload["code"] == "VERSION_CONFLICT"
|
||||
assert uuid.UUID(payload["trace_id"])
|
||||
assert payload["field_errors"] == []
|
||||
@@ -46,6 +46,27 @@ def test_base_urls_drop_trailing_slashes() -> None:
|
||||
assert settings.bailian_rerank_base_url.endswith("/v1")
|
||||
|
||||
|
||||
def test_model_gateway_url_is_fixed_to_internal_service() -> None:
|
||||
settings = Settings(model_gateway_base_url="http://model-gateway:8000/")
|
||||
|
||||
assert settings.model_gateway_base_url == "http://model-gateway:8000"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"https://model-gateway:8000",
|
||||
"http://model-gateway:9000",
|
||||
"http://127.0.0.1:8000",
|
||||
"http://model-gateway:8000/proxy",
|
||||
"http://user:model-token@model-gateway:8000",
|
||||
],
|
||||
)
|
||||
def test_model_gateway_url_rejects_ssrf_and_credential_variants(value: str) -> None:
|
||||
with pytest.raises(ValueError, match="fixed internal service URL"):
|
||||
Settings(model_gateway_base_url=value)
|
||||
|
||||
|
||||
def test_embedding_dimension_accepts_compose_string(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("EMBEDDING_DIMENSION", "1024")
|
||||
|
||||
|
||||
748
backend/tests/unit/test_model_gateway.py
Normal file
748
backend/tests/unit/test_model_gateway.py
Normal file
@@ -0,0 +1,748 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from starlette.requests import ClientDisconnect
|
||||
from starlette.types import Message, Scope
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.model_gateway import create_model_gateway_app
|
||||
from app.ports.model_providers import (
|
||||
ChatCompletionResult,
|
||||
ChatMessage,
|
||||
ChatStreamEvent,
|
||||
EmbeddingResult,
|
||||
ModelProviderError,
|
||||
ProviderErrorKind,
|
||||
ProviderUsage,
|
||||
RankedItem,
|
||||
RerankResult,
|
||||
)
|
||||
|
||||
API_TOKEN = "test-only-api-token" # noqa: S105
|
||||
WORKER_TOKEN = "test-only-worker-token" # noqa: S105
|
||||
ALLOWED_TOKENS = {"api": API_TOKEN, "worker": WORKER_TOKEN}
|
||||
|
||||
|
||||
class _FakeEmbedding:
|
||||
def __init__(self) -> None:
|
||||
self.document_calls = 0
|
||||
self.query_calls = 0
|
||||
self.active = 0
|
||||
self.max_active = 0
|
||||
self.delay = 0.0
|
||||
self.error: Exception | None = None
|
||||
|
||||
async def _result(self, texts: Sequence[str]) -> EmbeddingResult:
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
try:
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
return EmbeddingResult(
|
||||
vectors=tuple((float(index + 1), 0.5) for index, _ in enumerate(texts)),
|
||||
model="fake-embedding",
|
||||
request_id="req-embedding",
|
||||
usage=ProviderUsage(input_tokens=len(texts), total_tokens=len(texts)),
|
||||
elapsed_ms=1.25,
|
||||
)
|
||||
finally:
|
||||
self.active -= 1
|
||||
|
||||
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult:
|
||||
self.document_calls += 1
|
||||
return await self._result(texts)
|
||||
|
||||
async def embed_query(self, text: str) -> EmbeddingResult:
|
||||
self.query_calls += 1
|
||||
return await self._result([text])
|
||||
|
||||
|
||||
class _FakeReranker:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def rerank(
|
||||
self,
|
||||
query: str,
|
||||
documents: Sequence[str],
|
||||
*,
|
||||
top_n: int,
|
||||
instruct: str | None = None,
|
||||
) -> RerankResult:
|
||||
del query, instruct
|
||||
self.calls += 1
|
||||
return RerankResult(
|
||||
items=tuple(
|
||||
RankedItem(index=index, relevance_score=1.0 - index / 10, document=document)
|
||||
for index, document in enumerate(documents[:top_n])
|
||||
),
|
||||
model="fake-reranker",
|
||||
request_id="req-rerank",
|
||||
usage=ProviderUsage(input_tokens=len(documents), total_tokens=len(documents)),
|
||||
elapsed_ms=2.5,
|
||||
)
|
||||
|
||||
|
||||
class _FakeChat:
|
||||
def __init__(self) -> None:
|
||||
self.complete_calls = 0
|
||||
self.stream_error: ModelProviderError | None = None
|
||||
self.stream_events: tuple[ChatStreamEvent, ...] | None = None
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
messages: Sequence[ChatMessage],
|
||||
*,
|
||||
max_tokens: int,
|
||||
) -> ChatCompletionResult:
|
||||
del max_tokens
|
||||
self.complete_calls += 1
|
||||
return ChatCompletionResult(
|
||||
content=f"answer:{messages[-1].content}",
|
||||
finish_reason="stop",
|
||||
model="fake-chat",
|
||||
request_id="req-chat",
|
||||
usage=ProviderUsage(input_tokens=3, output_tokens=2, total_tokens=5),
|
||||
elapsed_ms=3.75,
|
||||
)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: Sequence[ChatMessage],
|
||||
*,
|
||||
max_tokens: int,
|
||||
) -> AsyncIterator[ChatStreamEvent]:
|
||||
del messages, max_tokens
|
||||
if self.stream_error is not None:
|
||||
raise self.stream_error
|
||||
if self.stream_events is not None:
|
||||
for event in self.stream_events:
|
||||
yield event
|
||||
return
|
||||
yield ChatStreamEvent(
|
||||
delta="第一段",
|
||||
finish_reason=None,
|
||||
model="fake-chat",
|
||||
request_id="req-stream",
|
||||
usage=ProviderUsage(),
|
||||
elapsed_ms=1.0,
|
||||
)
|
||||
yield ChatStreamEvent(
|
||||
delta="第二段",
|
||||
finish_reason="stop",
|
||||
model="fake-chat",
|
||||
request_id="req-stream",
|
||||
usage=ProviderUsage(input_tokens=3, output_tokens=2, total_tokens=5),
|
||||
elapsed_ms=2.0,
|
||||
)
|
||||
|
||||
|
||||
class _TrackedChatStream:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.emitted = False
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[ChatStreamEvent]:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> ChatStreamEvent:
|
||||
if self.emitted:
|
||||
await asyncio.Event().wait()
|
||||
raise StopAsyncIteration
|
||||
self.emitted = True
|
||||
return ChatStreamEvent(
|
||||
delta="first",
|
||||
finish_reason=None,
|
||||
model="fake-chat",
|
||||
request_id="req-stream",
|
||||
usage=ProviderUsage(),
|
||||
elapsed_ms=1.0,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _TrackedChat(_FakeChat):
|
||||
def __init__(self, stream: _TrackedChatStream) -> None:
|
||||
super().__init__()
|
||||
self.tracked_stream = stream
|
||||
|
||||
def stream(
|
||||
self,
|
||||
messages: Sequence[ChatMessage],
|
||||
*,
|
||||
max_tokens: int,
|
||||
) -> AsyncIterator[ChatStreamEvent]:
|
||||
del messages, max_tokens
|
||||
return self.tracked_stream
|
||||
|
||||
|
||||
class _ExplodingEmbeddingResult:
|
||||
@property
|
||||
def vectors(self) -> tuple[tuple[float, ...], ...]:
|
||||
raise RuntimeError("private-dto-exception-text")
|
||||
|
||||
|
||||
class _MalformedEmbedding(_FakeEmbedding):
|
||||
async def embed_query(self, text: str) -> EmbeddingResult:
|
||||
del text
|
||||
self.query_calls += 1
|
||||
return cast(EmbeddingResult, _ExplodingEmbeddingResult())
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _client(
|
||||
*,
|
||||
embedding: _FakeEmbedding | None = None,
|
||||
reranker: _FakeReranker | None = None,
|
||||
chat: _FakeChat | None = None,
|
||||
max_concurrency: int = 4,
|
||||
) -> AsyncIterator[tuple[httpx.AsyncClient, FastAPI, _FakeEmbedding, _FakeReranker, _FakeChat]]:
|
||||
embedding = embedding or _FakeEmbedding()
|
||||
reranker = reranker or _FakeReranker()
|
||||
chat = chat or _FakeChat()
|
||||
app = create_model_gateway_app(
|
||||
embedding_provider=embedding,
|
||||
reranker=reranker,
|
||||
chat_provider=chat,
|
||||
allowed_tokens=ALLOWED_TOKENS,
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://model-gateway"
|
||||
) as client:
|
||||
yield client, app, embedding, reranker, chat
|
||||
|
||||
|
||||
def _headers(caller: str = "api", token: str = API_TOKEN) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}", "X-RAG-Caller": caller}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_is_unauthenticated_and_schema_endpoints_are_disabled() -> None:
|
||||
async with _client() as (client, app, _, __, ___):
|
||||
live = await client.get("/health/live")
|
||||
ready = await client.get("/health/ready")
|
||||
docs = await client.get("/docs")
|
||||
openapi = await client.get("/openapi.json")
|
||||
|
||||
assert app.docs_url is None
|
||||
assert app.redoc_url is None
|
||||
assert app.openapi_url is None
|
||||
assert live.status_code == 200
|
||||
assert ready.json() == {"status": "ready", "checks": {"configuration": "ok"}}
|
||||
assert docs.status_code == 404
|
||||
assert openapi.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_authentication_binds_token_to_declared_caller() -> None:
|
||||
async with _client() as (client, _, embedding, __, ___):
|
||||
payload = {"texts": ["斑岩铜矿"], "input_type": "query"}
|
||||
missing = await client.post("/internal/v1/embeddings", json=payload)
|
||||
wrong = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json=payload,
|
||||
headers=_headers(token="wrong-and-sensitive-token"),
|
||||
)
|
||||
mismatched = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json=payload,
|
||||
headers=_headers(caller="worker", token=API_TOKEN),
|
||||
)
|
||||
|
||||
assert [missing.status_code, wrong.status_code, mismatched.status_code] == [401, 401, 401]
|
||||
assert missing.json()["error"]["kind"] == "unauthorized"
|
||||
assert "wrong-and-sensitive-token" not in wrong.text
|
||||
assert embedding.query_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_scope_and_response_contract() -> None:
|
||||
async with _client() as (client, _, embedding, __, ___):
|
||||
query = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["斑岩铜矿"], "input_type": "query"},
|
||||
headers=_headers(),
|
||||
)
|
||||
forbidden = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["内部地质报告"], "input_type": "document"},
|
||||
headers=_headers(),
|
||||
)
|
||||
document = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["文档一", "文档二"], "input_type": "document"},
|
||||
headers=_headers(caller="worker", token=WORKER_TOKEN),
|
||||
)
|
||||
|
||||
assert query.status_code == 200
|
||||
assert query.json() == {
|
||||
"vectors": [[1.0, 0.5]],
|
||||
"model": "fake-embedding",
|
||||
"request_id": "req-embedding",
|
||||
"usage": {"input_tokens": 1, "output_tokens": None, "total_tokens": 1},
|
||||
"elapsed_ms": 1.25,
|
||||
}
|
||||
assert forbidden.status_code == 403
|
||||
assert document.status_code == 200
|
||||
assert len(document.json()["vectors"]) == 2
|
||||
assert embedding.query_calls == 1
|
||||
assert embedding.document_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_error_does_not_echo_rejected_content() -> None:
|
||||
sensitive = "private-geological-report-fragment"
|
||||
async with _client() as (client, _, embedding, __, ___):
|
||||
response = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": [sensitive, "second query"], "input_type": "query"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"error": {"kind": "invalid_request", "retryable": False, "request_id": None}
|
||||
}
|
||||
assert sensitive not in response.text
|
||||
assert embedding.query_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rerank_and_chat_completion_contracts() -> None:
|
||||
async with _client() as (client, _, __, reranker, chat):
|
||||
rerank_response = await client.post(
|
||||
"/internal/v1/rerank",
|
||||
json={"query": "铜矿", "documents": ["铜矿蚀变", "煤层"], "top_n": 1},
|
||||
headers=_headers(),
|
||||
)
|
||||
chat_response = await client.post(
|
||||
"/internal/v1/chat/completions",
|
||||
json={"messages": [{"role": "user", "content": "结论是什么?"}], "max_tokens": 32},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert rerank_response.status_code == 200
|
||||
assert rerank_response.json()["items"] == [
|
||||
{"index": 0, "relevance_score": 1.0, "document": "铜矿蚀变"}
|
||||
]
|
||||
assert chat_response.status_code == 200
|
||||
assert chat_response.json()["content"] == "answer:结论是什么?"
|
||||
assert chat_response.json()["usage"]["total_tokens"] == 5
|
||||
assert reranker.calls == 1
|
||||
assert chat.complete_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_error_mapping_is_stable_and_redacted() -> None:
|
||||
embedding = _FakeEmbedding()
|
||||
embedding.error = ModelProviderError(
|
||||
operation="embedding.create.private-query",
|
||||
kind=ProviderErrorKind.RATE_LIMITED,
|
||||
status_code=429,
|
||||
provider_code="private-upstream-body",
|
||||
request_id="req-safe",
|
||||
retryable=True,
|
||||
)
|
||||
async with _client(embedding=embedding) as (client, _, __, ___, ____):
|
||||
response = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["private-input-text"], "input_type": "query"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.json() == {
|
||||
"error": {"kind": "rate_limited", "retryable": True, "request_id": "req-safe"}
|
||||
}
|
||||
assert "private" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_provider_and_dto_failures_are_locally_sanitized(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
provider_failure = _FakeEmbedding()
|
||||
provider_failure.error = RuntimeError("private-provider-exception-text")
|
||||
async with _client(embedding=provider_failure) as (client, _, __, ___, ____):
|
||||
provider_response = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["private-provider-input"], "input_type": "query"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
malformed = _MalformedEmbedding()
|
||||
async with _client(embedding=malformed) as (client, _, __, ___, ____):
|
||||
dto_response = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["private-dto-input"], "input_type": "query"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
safe_error = {"error": {"kind": "invalid_response", "retryable": False, "request_id": None}}
|
||||
assert provider_response.status_code == 502
|
||||
assert provider_response.json() == safe_error
|
||||
assert dto_response.status_code == 502
|
||||
assert dto_response.json() == safe_error
|
||||
assert "private-provider" not in provider_response.text
|
||||
assert "private-dto" not in dto_response.text
|
||||
assert "private-provider-exception-text" not in caplog.text
|
||||
assert "private-dto-exception-text" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_emits_safe_delta_complete_and_error_events() -> None:
|
||||
chat = _FakeChat()
|
||||
async with _client(chat=chat) as (client, _, __, ___, ____):
|
||||
success = await client.post(
|
||||
"/internal/v1/chat/stream",
|
||||
json={"messages": [{"role": "user", "content": "回答"}], "max_tokens": 32},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert success.status_code == 200
|
||||
assert success.headers["content-type"].startswith("text/event-stream")
|
||||
assert success.text.count("event: delta") == 2
|
||||
assert "event: complete" in success.text
|
||||
assert '"total_tokens":5' in success.text
|
||||
|
||||
chat.stream_error = ModelProviderError(
|
||||
operation="chat.stream.private",
|
||||
kind=ProviderErrorKind.AUTHENTICATION,
|
||||
provider_code="private-secret-body",
|
||||
request_id="req-stream-safe",
|
||||
)
|
||||
async with _client(chat=chat) as (client, _, __, ___, ____):
|
||||
failure = await client.post(
|
||||
"/internal/v1/chat/stream",
|
||||
json={"messages": [{"role": "user", "content": "private-question"}]},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert failure.status_code == 200
|
||||
assert "event: error" in failure.text
|
||||
assert '"kind":"authentication"' in failure.text
|
||||
assert "private" not in failure.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_aggregates_terminal_and_later_usage_only_event() -> None:
|
||||
chat = _FakeChat()
|
||||
chat.stream_events = (
|
||||
ChatStreamEvent(
|
||||
delta="答案",
|
||||
finish_reason=None,
|
||||
model="fake-chat-first",
|
||||
request_id="req-first",
|
||||
usage=ProviderUsage(input_tokens=3),
|
||||
elapsed_ms=4.0,
|
||||
),
|
||||
ChatStreamEvent(
|
||||
delta="",
|
||||
finish_reason="stop",
|
||||
model="",
|
||||
request_id=None,
|
||||
usage=ProviderUsage(),
|
||||
elapsed_ms=2.0,
|
||||
),
|
||||
ChatStreamEvent(
|
||||
delta="",
|
||||
finish_reason=None,
|
||||
model="fake-chat-final",
|
||||
request_id="req-final",
|
||||
usage=ProviderUsage(output_tokens=2, total_tokens=5),
|
||||
elapsed_ms=7.0,
|
||||
),
|
||||
)
|
||||
|
||||
async with _client(chat=chat) as (client, _, __, ___, ____):
|
||||
response = await client.post(
|
||||
"/internal/v1/chat/stream",
|
||||
json={"messages": [{"role": "user", "content": "回答"}]},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
complete_block = next(
|
||||
block for block in response.text.split("\n\n") if block.startswith("event: complete")
|
||||
)
|
||||
complete_payload = json.loads(complete_block.split("data: ", maxsplit=1)[1])
|
||||
assert complete_payload == {
|
||||
"elapsed_ms": 7.0,
|
||||
"finish_reason": "stop",
|
||||
"model": "fake-chat-final",
|
||||
"request_id": "req-final",
|
||||
"usage": {"input_tokens": 3, "output_tokens": 2, "total_tokens": 5},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_without_legal_terminal_finish_emits_error_not_complete() -> None:
|
||||
chat = _FakeChat()
|
||||
chat.stream_events = (
|
||||
ChatStreamEvent(
|
||||
delta="未完成答案",
|
||||
finish_reason=None,
|
||||
model="fake-chat",
|
||||
request_id="req-incomplete",
|
||||
usage=ProviderUsage(),
|
||||
elapsed_ms=1.0,
|
||||
),
|
||||
ChatStreamEvent(
|
||||
delta="",
|
||||
finish_reason=None,
|
||||
model="fake-chat",
|
||||
request_id="req-incomplete",
|
||||
usage=ProviderUsage(total_tokens=5),
|
||||
elapsed_ms=2.0,
|
||||
),
|
||||
)
|
||||
|
||||
async with _client(chat=chat) as (client, _, __, ___, ____):
|
||||
response = await client.post(
|
||||
"/internal/v1/chat/stream",
|
||||
json={"messages": [{"role": "user", "content": "回答"}]},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert "event: complete" not in response.text
|
||||
assert "event: error" in response.text
|
||||
assert '"kind":"invalid_response"' in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrency_is_bounded_across_requests() -> None:
|
||||
embedding = _FakeEmbedding()
|
||||
embedding.delay = 0.02
|
||||
async with _client(embedding=embedding, max_concurrency=1) as (client, _, __, ___, ____):
|
||||
responses = await asyncio.gather(
|
||||
client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["query one"], "input_type": "query"},
|
||||
headers=_headers(),
|
||||
),
|
||||
client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["query two"], "input_type": "query"},
|
||||
headers=_headers(),
|
||||
),
|
||||
)
|
||||
|
||||
assert [response.status_code for response in responses] == [200, 200]
|
||||
assert embedding.max_active == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_is_closed_when_downstream_disconnects() -> None:
|
||||
tracked_stream = _TrackedChatStream()
|
||||
chat = _TrackedChat(tracked_stream)
|
||||
embedding = _FakeEmbedding()
|
||||
reranker = _FakeReranker()
|
||||
app = create_model_gateway_app(
|
||||
embedding_provider=embedding,
|
||||
reranker=reranker,
|
||||
chat_provider=chat,
|
||||
allowed_tokens=ALLOWED_TOKENS,
|
||||
)
|
||||
body = json.dumps(
|
||||
{"messages": [{"role": "user", "content": "stream"}], "max_tokens": 8}
|
||||
).encode()
|
||||
request_delivered = False
|
||||
|
||||
async def receive() -> Message:
|
||||
nonlocal request_delivered
|
||||
if not request_delivered:
|
||||
request_delivered = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
async def disconnect_on_first_body(message: Message) -> None:
|
||||
if message["type"] == "http.response.body" and message.get("body"):
|
||||
raise OSError("downstream disconnected")
|
||||
|
||||
scope = cast(
|
||||
Scope,
|
||||
{
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0", "spec_version": "2.4"},
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"scheme": "http",
|
||||
"path": "/internal/v1/chat/stream",
|
||||
"raw_path": b"/internal/v1/chat/stream",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(body)).encode()),
|
||||
(b"authorization", f"Bearer {API_TOKEN}".encode()),
|
||||
(b"x-rag-caller", b"api"),
|
||||
],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("model-gateway", 8000),
|
||||
},
|
||||
)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
with pytest.raises(ClientDisconnect):
|
||||
await app(scope, receive, disconnect_on_first_body)
|
||||
|
||||
assert tracked_stream.emitted is True
|
||||
assert tracked_stream.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_production_readiness_reads_local_secrets_without_cloud_call(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
api_key = tmp_path / "bailian"
|
||||
api_token = tmp_path / "api-token"
|
||||
worker_token = tmp_path / "worker-token"
|
||||
api_key.write_text("test-only-bailian-key", encoding="utf-8")
|
||||
api_token.write_text(API_TOKEN, encoding="utf-8")
|
||||
worker_token.write_text(WORKER_TOKEN, encoding="utf-8")
|
||||
monkeypatch.setenv(
|
||||
"MODEL_GATEWAY_ALLOWED_TOKEN_FILES",
|
||||
f"api={api_token},worker={worker_token}",
|
||||
)
|
||||
settings = Settings(
|
||||
dashscope_api_key_file=api_key,
|
||||
bailian_openai_base_url=(
|
||||
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||
),
|
||||
bailian_rerank_base_url=(
|
||||
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
|
||||
),
|
||||
)
|
||||
app = create_model_gateway_app(settings_factory=lambda: settings)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://model-gateway"
|
||||
) as client:
|
||||
response = await client.get("/health/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ready", "checks": {"configuration": "ok"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotated_internal_token_fuses_old_process_until_coordinated_restart(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
api_key = tmp_path / "bailian"
|
||||
api_token = tmp_path / "api-token"
|
||||
worker_token = tmp_path / "worker-token"
|
||||
api_key.write_text("test-only-bailian-key", encoding="utf-8")
|
||||
api_token.write_text(API_TOKEN, encoding="utf-8")
|
||||
worker_token.write_text(WORKER_TOKEN, encoding="utf-8")
|
||||
monkeypatch.setenv(
|
||||
"MODEL_GATEWAY_ALLOWED_TOKEN_FILES",
|
||||
f"api={api_token},worker={worker_token}",
|
||||
)
|
||||
settings = Settings(
|
||||
dashscope_api_key_file=api_key,
|
||||
bailian_openai_base_url=(
|
||||
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||
),
|
||||
bailian_rerank_base_url=(
|
||||
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
|
||||
),
|
||||
)
|
||||
app = create_model_gateway_app(settings_factory=lambda: settings)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://model-gateway"
|
||||
) as client:
|
||||
assert (await client.get("/health/ready")).status_code == 200
|
||||
rotated_token = "test-only-rotated-api-token"
|
||||
api_token.write_text(rotated_token, encoding="utf-8")
|
||||
old_credential = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["query"], "input_type": "query"},
|
||||
headers=_headers(),
|
||||
)
|
||||
new_credential = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["query"], "input_type": "query"},
|
||||
headers=_headers(token=rotated_token),
|
||||
)
|
||||
unhealthy = await client.get("/health/ready")
|
||||
|
||||
expected_restart = {
|
||||
"error": {"kind": "restart_required", "retryable": False, "request_id": None}
|
||||
}
|
||||
assert old_credential.status_code == 503
|
||||
assert old_credential.json() == expected_restart
|
||||
assert new_credential.status_code == 503
|
||||
assert new_credential.json() == expected_restart
|
||||
assert unhealthy.status_code == 503
|
||||
assert unhealthy.json()["checks"] == {"configuration": "restart_required"}
|
||||
|
||||
replacement = create_model_gateway_app(settings_factory=lambda: settings)
|
||||
async with replacement.router.lifespan_context(replacement):
|
||||
transport = httpx.ASGITransport(app=replacement)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://replacement-model-gateway"
|
||||
) as client:
|
||||
recovered = await client.get("/health/ready")
|
||||
assert recovered.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotated_bailian_key_is_detected_before_any_business_call(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
api_key = tmp_path / "bailian"
|
||||
api_token = tmp_path / "api-token"
|
||||
worker_token = tmp_path / "worker-token"
|
||||
api_key.write_text("test-only-bailian-key", encoding="utf-8")
|
||||
api_token.write_text(API_TOKEN, encoding="utf-8")
|
||||
worker_token.write_text(WORKER_TOKEN, encoding="utf-8")
|
||||
monkeypatch.setenv(
|
||||
"MODEL_GATEWAY_ALLOWED_TOKEN_FILES",
|
||||
f"api={api_token},worker={worker_token}",
|
||||
)
|
||||
settings = Settings(
|
||||
dashscope_api_key_file=api_key,
|
||||
bailian_openai_base_url=(
|
||||
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
||||
),
|
||||
bailian_rerank_base_url=(
|
||||
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
|
||||
),
|
||||
)
|
||||
app = create_model_gateway_app(settings_factory=lambda: settings)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://model-gateway"
|
||||
) as client:
|
||||
assert (await client.get("/health/ready")).status_code == 200
|
||||
api_key.write_text("test-only-rotated-bailian-key", encoding="utf-8")
|
||||
response = await client.post(
|
||||
"/internal/v1/embeddings",
|
||||
json={"texts": ["must-not-reach-cloud"], "input_type": "query"},
|
||||
headers=_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["error"]["kind"] == "restart_required"
|
||||
275
backend/tests/unit/test_model_gateway_client.py
Normal file
275
backend/tests/unit/test_model_gateway_client.py
Normal file
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.adapters.model_gateway import ModelGatewayAdapter
|
||||
from app.ports.model_providers import ChatMessage, ModelProviderError, ProviderErrorKind
|
||||
|
||||
|
||||
def _usage() -> dict[str, int]:
|
||||
return {"input_tokens": 4, "output_tokens": 0, "total_tokens": 4}
|
||||
|
||||
|
||||
def _vector() -> list[float]:
|
||||
return [1.0] + [0.0] * 1023
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_embedding_uses_fixed_origin_auth_and_query_type() -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"vectors": [_vector()],
|
||||
"model": "text-embedding-v4",
|
||||
"request_id": "req-1",
|
||||
"usage": _usage(),
|
||||
"elapsed_ms": 8.5,
|
||||
},
|
||||
)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
|
||||
try:
|
||||
result = await adapter.embed_query("斑岩铜矿")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert len(result.vectors[0]) == 1024
|
||||
assert seen[0].url == "http://model-gateway:8000/internal/v1/embeddings"
|
||||
assert seen[0].headers["authorization"] == "Bearer internal-token"
|
||||
assert seen[0].headers["x-rag-caller"] == "api"
|
||||
assert json.loads(seen[0].content) == {"texts": ["斑岩铜矿"], "input_type": "query"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_embedding_identifies_worker_caller() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["x-rag-caller"] == "worker"
|
||||
assert json.loads(request.content)["input_type"] == "document"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"vectors": [_vector()],
|
||||
"model": "text-embedding-v4",
|
||||
"request_id": None,
|
||||
"usage": _usage(),
|
||||
"elapsed_ms": 1,
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelGatewayAdapter(token="worker-token", caller="worker", http_client=client)
|
||||
await adapter.embed_documents(["合成文档"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_http_error_does_not_leak_response_or_token() -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, json={"detail": "internal-token private upstream body"})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
|
||||
with pytest.raises(ModelProviderError) as caught:
|
||||
await adapter.embed_query("secret query")
|
||||
|
||||
assert caught.value.kind is ProviderErrorKind.AUTHENTICATION
|
||||
assert "internal-token" not in str(caught.value)
|
||||
assert "secret query" not in str(caught.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_preserves_only_fixed_provider_error_category() -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
502,
|
||||
json={
|
||||
"error": {
|
||||
"kind": "authentication",
|
||||
"retryable": False,
|
||||
"request_id": "req-safe",
|
||||
"untrusted_extra": "must-not-cross-boundary",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
|
||||
with pytest.raises(ModelProviderError) as caught:
|
||||
await adapter.embed_query("secret query")
|
||||
|
||||
assert caught.value.kind is ProviderErrorKind.AUTHENTICATION
|
||||
assert caught.value.status_code == 502
|
||||
assert caught.value.request_id == "req-safe"
|
||||
assert "untrusted_extra" not in str(caught.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rerank_rejects_document_mismatch_from_gateway() -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"items": [{"index": 0, "relevance_score": 0.9, "document": "tampered"}],
|
||||
"model": "qwen3-rerank",
|
||||
"request_id": "req-2",
|
||||
"usage": _usage(),
|
||||
"elapsed_ms": 3,
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
|
||||
with pytest.raises(ModelProviderError) as caught:
|
||||
await adapter.rerank("铜矿", ["候选"], top_n=1)
|
||||
|
||||
assert caught.value.kind is ProviderErrorKind.INVALID_RESPONSE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_completion_maps_gateway_response() -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"content": "仅依据证据回答。[S1]",
|
||||
"finish_reason": "stop",
|
||||
"model": "deepseek-v4-flash",
|
||||
"request_id": "req-chat",
|
||||
"usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8},
|
||||
"elapsed_ms": 9,
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
|
||||
result = await adapter.complete([ChatMessage(role="user", content="问题")], max_tokens=20)
|
||||
|
||||
assert result.content.endswith("[S1]")
|
||||
assert result.usage.total_tokens == 8
|
||||
|
||||
|
||||
class _SseStream(httpx.AsyncByteStream):
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
yield (
|
||||
b"event: delta\n"
|
||||
b'data: {"delta":"answer","model":"deepseek-v4-flash",'
|
||||
b'"request_id":"req"}\n\n'
|
||||
)
|
||||
yield (
|
||||
b"event: complete\n"
|
||||
b'data: {"delta":"","finish_reason":"stop",'
|
||||
b'"model":"deepseek-v4-flash","request_id":"req",'
|
||||
b'"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3},'
|
||||
b'"elapsed_ms":5}\n\n'
|
||||
)
|
||||
|
||||
|
||||
class _IncompleteSseStream(httpx.AsyncByteStream):
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
yield (
|
||||
b"event: delta\n"
|
||||
b'data: {"delta":"partial","model":"deepseek-v4-flash",'
|
||||
b'"request_id":"req"}\n\n'
|
||||
)
|
||||
|
||||
|
||||
class _EventAfterCompleteStream(httpx.AsyncByteStream):
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
yield (
|
||||
b"event: complete\n"
|
||||
b'data: {"finish_reason":"stop","model":"deepseek-v4-flash",'
|
||||
b'"request_id":"req","usage":{"input_tokens":1,"output_tokens":1,'
|
||||
b'"total_tokens":2},"elapsed_ms":5}\n\n'
|
||||
)
|
||||
yield (
|
||||
b"event: delta\n"
|
||||
b'data: {"delta":"late","model":"deepseek-v4-flash",'
|
||||
b'"request_id":"req"}\n\n'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_parses_typed_events() -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=_SseStream(),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
|
||||
events = [
|
||||
event
|
||||
async for event in adapter.stream(
|
||||
[ChatMessage(role="user", content="question")], max_tokens=20
|
||||
)
|
||||
]
|
||||
|
||||
assert [event.delta for event in events] == ["answer", ""]
|
||||
assert events[-1].finish_reason == "stop"
|
||||
assert events[-1].usage.total_tokens == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("stream", [_IncompleteSseStream(), _EventAfterCompleteStream()])
|
||||
async def test_chat_stream_requires_exactly_one_terminal_complete(
|
||||
stream: httpx.AsyncByteStream,
|
||||
) -> None:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
|
||||
with pytest.raises(ModelProviderError) as caught:
|
||||
_ = [
|
||||
event
|
||||
async for event in adapter.stream(
|
||||
[ChatMessage(role="user", content="question")], max_tokens=20
|
||||
)
|
||||
]
|
||||
|
||||
assert caught.value.kind is ProviderErrorKind.INVALID_RESPONSE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_stream_rejects_nonfinite_terminal_metadata() -> None:
|
||||
class InvalidTerminal(httpx.AsyncByteStream):
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
yield (
|
||||
b"event: complete\n"
|
||||
b'data: {"finish_reason":"stop","model":"deepseek-v4-flash",'
|
||||
b'"request_id":"req","usage":{},"elapsed_ms":-1}\n\n'
|
||||
)
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, stream=InvalidTerminal())
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
|
||||
with pytest.raises(ModelProviderError) as caught:
|
||||
_ = [
|
||||
event
|
||||
async for event in adapter.stream(
|
||||
[ChatMessage(role="user", content="question")], max_tokens=20
|
||||
)
|
||||
]
|
||||
|
||||
assert caught.value.kind is ProviderErrorKind.INVALID_RESPONSE
|
||||
|
||||
|
||||
def test_client_rejects_any_nonfixed_gateway_origin() -> None:
|
||||
with pytest.raises(ModelProviderError):
|
||||
ModelGatewayAdapter(token="token", caller="api", base_url="http://127.0.0.1:8000")
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from app.adapters.model_gateway import ModelGatewayAdapter
|
||||
from app.core.config import Settings
|
||||
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
|
||||
from app.tools import provider_smoke
|
||||
@@ -19,28 +20,23 @@ def test_provider_smoke_settings_accept_compose_embedding_dimension(
|
||||
async def test_provider_smoke_entrypoint_accepts_compose_embedding_dimension(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
workspace_host = "workspace-test.cn-beijing.maas.aliyuncs.com"
|
||||
monkeypatch.setenv("EMBEDDING_DIMENSION", "1024")
|
||||
monkeypatch.setenv(
|
||||
"BAILIAN_OPENAI_BASE_URL",
|
||||
f"https://{workspace_host}/compatible-mode/v1",
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
"BAILIAN_RERANK_BASE_URL",
|
||||
f"https://{workspace_host}/compatible-api/v1",
|
||||
)
|
||||
observed_dimensions: list[int] = []
|
||||
|
||||
def fake_api_key(settings: Settings) -> str:
|
||||
observed_dimensions.append(settings.embedding_dimension)
|
||||
return "test-only-api-key"
|
||||
class StubGateway:
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
async def successful_probe(settings: Settings, api_key: str) -> ProbeResult:
|
||||
def fake_gateway(settings: Settings) -> StubGateway:
|
||||
observed_dimensions.append(settings.embedding_dimension)
|
||||
assert api_key == "test-only-api-key"
|
||||
return StubGateway()
|
||||
|
||||
async def successful_probe(settings: Settings, adapter: StubGateway) -> ProbeResult:
|
||||
observed_dimensions.append(settings.embedding_dimension)
|
||||
assert isinstance(adapter, StubGateway)
|
||||
return ProbeResult(capability="test", status="ok")
|
||||
|
||||
monkeypatch.setattr(Settings, "bailian_api_key", fake_api_key)
|
||||
monkeypatch.setattr(ModelGatewayAdapter, "from_settings", staticmethod(fake_gateway))
|
||||
monkeypatch.setattr(provider_smoke, "probe_embedding", successful_probe)
|
||||
monkeypatch.setattr(provider_smoke, "probe_rerank", successful_probe)
|
||||
monkeypatch.setattr(provider_smoke, "probe_chat", successful_probe)
|
||||
|
||||
@@ -5,9 +5,12 @@ import pytest
|
||||
from app.adapters.fake import FakeEmbeddingProvider
|
||||
from app.core.config import Settings
|
||||
from app.tools.seed_demo import (
|
||||
BAILIAN_NAMESPACE,
|
||||
OFFLINE_NAMESPACE,
|
||||
embed_in_batches,
|
||||
embedding_profile_hash,
|
||||
load_documents,
|
||||
namespace_for_mode,
|
||||
prepare_chunks,
|
||||
)
|
||||
|
||||
@@ -38,6 +41,44 @@ async def test_seed_preparation_batches_and_hash_binds_twenty_documents() -> Non
|
||||
assert all(item.embedding_text == item.embedding_prefix + item.cloud_text for item in chunks)
|
||||
assert len({item.chunk_id for item in chunks}) == 20
|
||||
assert all(len(item.outbound_manifest_sha256) == 64 for item in chunks)
|
||||
assert all(item.embedding_elapsed_ms >= 0 for item in chunks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_and_offline_seed_namespaces_cannot_share_document_identity() -> None:
|
||||
documents = load_documents(DOCUMENTS_PATH)
|
||||
settings = Settings()
|
||||
vectors, model = await embed_in_batches(
|
||||
FakeEmbeddingProvider(),
|
||||
[f"标题:{item.title}\n正文:{item.content}" for item in documents],
|
||||
)
|
||||
profile_hash = embedding_profile_hash(settings, "fake")
|
||||
|
||||
offline = prepare_chunks(
|
||||
documents,
|
||||
vectors,
|
||||
profile_hash=profile_hash,
|
||||
embedding_model=model,
|
||||
namespace=OFFLINE_NAMESPACE,
|
||||
)
|
||||
live = prepare_chunks(
|
||||
documents,
|
||||
vectors,
|
||||
profile_hash=profile_hash,
|
||||
embedding_model=model,
|
||||
namespace=BAILIAN_NAMESPACE,
|
||||
)
|
||||
|
||||
assert OFFLINE_NAMESPACE.knowledge_base_id != BAILIAN_NAMESPACE.knowledge_base_id
|
||||
assert OFFLINE_NAMESPACE.access_scope_id != BAILIAN_NAMESPACE.access_scope_id
|
||||
assert {item.document_id for item in offline}.isdisjoint({item.document_id for item in live})
|
||||
assert namespace_for_mode("fake") is OFFLINE_NAMESPACE
|
||||
assert namespace_for_mode("bailian") is BAILIAN_NAMESPACE
|
||||
|
||||
|
||||
def test_seed_rejects_unknown_provider_namespace() -> None:
|
||||
with pytest.raises(ValueError, match="invalid_provider_mode"):
|
||||
namespace_for_mode("unknown")
|
||||
|
||||
|
||||
def test_seed_rejects_fixture_not_explicitly_marked_synthetic(tmp_path: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user