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:
@@ -16,6 +16,11 @@ POSTGRES_APP_PASSWORD_FILE=/run/secrets/postgres_app_password
|
|||||||
UPLOAD_ROOT=/data/uploads
|
UPLOAD_ROOT=/data/uploads
|
||||||
MAX_UPLOAD_MB=100
|
MAX_UPLOAD_MB=100
|
||||||
|
|
||||||
|
MODEL_GATEWAY_BASE_URL=http://model-gateway:8000
|
||||||
|
MODEL_GATEWAY_TOKEN_FILE=/run/secrets/model_gateway_api_token
|
||||||
|
MODEL_GATEWAY_CALLER=api
|
||||||
|
MODEL_GATEWAY_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
# The actual workspace host is local deployment configuration and is not committed.
|
# The actual workspace host is local deployment configuration and is not committed.
|
||||||
BAILIAN_OPENAI_BASE_URL=https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
|
BAILIAN_OPENAI_BASE_URL=https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
|
||||||
BAILIAN_NATIVE_BASE_URL=https://<workspace-id>.cn-beijing.maas.aliyuncs.com/api/v1
|
BAILIAN_NATIVE_BASE_URL=https://<workspace-id>.cn-beijing.maas.aliyuncs.com/api/v1
|
||||||
|
|||||||
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")
|
upload_root: Path = Path("/data/uploads")
|
||||||
max_upload_mb: int = Field(default=100, ge=1, le=2048)
|
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 = (
|
bailian_openai_base_url: str = (
|
||||||
"https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
|
"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:
|
def normalize_base_url(cls, value: str) -> str:
|
||||||
return value.rstrip("/")
|
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")
|
@field_validator("embedding_dimension", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_embedding_dimension(cls, value: object) -> int:
|
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
|
from typing import Any
|
||||||
|
|
||||||
@@ -9,26 +9,17 @@ from fastapi import FastAPI, Response, status
|
|||||||
from app import __version__
|
from app import __version__
|
||||||
from app.api.v1 import demo_router
|
from app.api.v1 import demo_router
|
||||||
from app.core.config import get_settings
|
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
|
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]]
|
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]:
|
def live() -> dict[str, str]:
|
||||||
return {"status": "ok", "version": __version__}
|
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:
|
def ready(response: Response) -> HealthPayload:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
try:
|
try:
|
||||||
@@ -48,7 +39,6 @@ def ready(response: Response) -> HealthPayload:
|
|||||||
return {"status": "ready", "checks": {"database": "ok"}}
|
return {"status": "ready", "checks": {"database": "ok"}}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/meta", tags=["meta"])
|
|
||||||
def meta() -> dict[str, Any]:
|
def meta() -> dict[str, Any]:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
return {
|
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__":
|
if __name__ == "__main__":
|
||||||
# Compose publishes this listener only on the host loopback interface.
|
# Compose publishes this listener only on the host loopback interface.
|
||||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000) # noqa: S104
|
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 dataclasses import asdict, dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.adapters.bailian import (
|
from app.adapters.model_gateway import ModelGatewayAdapter
|
||||||
BailianChatAdapter,
|
|
||||||
BailianEmbeddingAdapter,
|
|
||||||
BailianRerankerAdapter,
|
|
||||||
)
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.core.secrets import SecretFileError
|
from app.core.secrets import SecretFileError
|
||||||
from app.ports.model_providers import ChatMessage, ModelProviderError
|
from app.ports.model_providers import ChatMessage, ModelProviderError
|
||||||
@@ -30,89 +26,59 @@ class ProbeResult:
|
|||||||
status_code: int | None = None
|
status_code: int | None = None
|
||||||
|
|
||||||
|
|
||||||
async def probe_embedding(settings: Settings, api_key: str) -> ProbeResult:
|
async def probe_embedding(settings: Settings, adapter: ModelGatewayAdapter) -> ProbeResult:
|
||||||
adapter = BailianEmbeddingAdapter(
|
# API identity probes query embedding. Document embedding remains worker-only.
|
||||||
api_key=api_key,
|
result = await adapter.embed_query("用于能力探测的虚构地质问题。")
|
||||||
base_url=settings.bailian_openai_base_url,
|
if len(result.vectors) != 1 or len(result.vectors[0]) != settings.embedding_dimension:
|
||||||
model=settings.embedding_model,
|
raise RuntimeError("embedding contract mismatch")
|
||||||
dimensions=settings.embedding_dimension,
|
return ProbeResult(
|
||||||
timeout_seconds=settings.model_timeout_seconds,
|
capability="embedding",
|
||||||
max_retries=settings.model_max_retries,
|
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:
|
async def probe_rerank(_: Settings, adapter: ModelGatewayAdapter) -> ProbeResult:
|
||||||
adapter = BailianRerankerAdapter(
|
result = await adapter.rerank(
|
||||||
api_key=api_key,
|
"哪段文本提到了斑岩铜矿?",
|
||||||
base_url=settings.bailian_rerank_base_url,
|
["虚构斑岩铜矿具有钾化带。", "虚构煤层采用测井曲线对比。"],
|
||||||
model=settings.rerank_model,
|
top_n=1,
|
||||||
timeout_seconds=settings.model_timeout_seconds,
|
|
||||||
max_retries=settings.model_max_retries,
|
|
||||||
)
|
)
|
||||||
try:
|
if len(result.items) != 1 or result.items[0].index not in (0, 1):
|
||||||
result = await adapter.rerank(
|
raise RuntimeError("rerank contract mismatch")
|
||||||
"哪段文本提到了斑岩铜矿?",
|
return ProbeResult(
|
||||||
["虚构斑岩铜矿具有钾化带。", "虚构煤层采用测井曲线对比。"],
|
capability="rerank",
|
||||||
top_n=1,
|
status="ok",
|
||||||
)
|
model=result.model,
|
||||||
if len(result.items) != 1 or result.items[0].index not in (0, 1):
|
elapsed_ms=round(result.elapsed_ms, 2),
|
||||||
raise RuntimeError("rerank contract mismatch")
|
request_id=result.request_id,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_chat(_: Settings, adapter: ModelGatewayAdapter) -> ProbeResult:
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
request_id: str | None = None
|
request_id: str | None = None
|
||||||
elapsed_ms = 0.0
|
elapsed_ms = 0.0
|
||||||
content_seen = False
|
content_seen = False
|
||||||
try:
|
async for event in adapter.stream(
|
||||||
async for event in adapter.stream(
|
[ChatMessage(role="user", content="只回复:能力正常")],
|
||||||
[ChatMessage(role="user", content="只回复:能力正常")],
|
max_tokens=16,
|
||||||
max_tokens=16,
|
):
|
||||||
):
|
model = event.model
|
||||||
model = event.model
|
request_id = event.request_id or request_id
|
||||||
request_id = event.request_id or request_id
|
elapsed_ms = max(elapsed_ms, event.elapsed_ms)
|
||||||
elapsed_ms = max(elapsed_ms, event.elapsed_ms)
|
content_seen = content_seen or bool(event.delta)
|
||||||
content_seen = content_seen or bool(event.delta)
|
if not content_seen:
|
||||||
if not content_seen:
|
raise RuntimeError("chat stream contained no text")
|
||||||
raise RuntimeError("chat stream contained no text")
|
return ProbeResult(
|
||||||
return ProbeResult(
|
capability="chat",
|
||||||
capability="chat",
|
status="ok",
|
||||||
status="ok",
|
model=model,
|
||||||
model=model,
|
elapsed_ms=round(elapsed_ms, 2),
|
||||||
elapsed_ms=round(elapsed_ms, 2),
|
request_id=request_id,
|
||||||
request_id=request_id,
|
)
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await adapter.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
def failed_probe(capability: str, error: BaseException) -> ProbeResult:
|
def failed_probe(capability: str, error: BaseException) -> ProbeResult:
|
||||||
@@ -133,12 +99,12 @@ def failed_probe(capability: str, error: BaseException) -> ProbeResult:
|
|||||||
|
|
||||||
async def run_probe(
|
async def run_probe(
|
||||||
capability: str,
|
capability: str,
|
||||||
operation: Callable[[Settings, str], Awaitable[ProbeResult]],
|
operation: Callable[[Settings, ModelGatewayAdapter], Awaitable[ProbeResult]],
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
api_key: str,
|
adapter: ModelGatewayAdapter,
|
||||||
) -> ProbeResult:
|
) -> ProbeResult:
|
||||||
try:
|
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.
|
except Exception as exc: # The output is deliberately reduced to a safe category.
|
||||||
return failed_probe(capability, exc)
|
return failed_probe(capability, exc)
|
||||||
|
|
||||||
@@ -148,17 +114,10 @@ def write_json_line(payload: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def async_main() -> int:
|
async def async_main() -> int:
|
||||||
|
adapter: ModelGatewayAdapter | None = None
|
||||||
try:
|
try:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
if any(
|
adapter = ModelGatewayAdapter.from_settings(settings)
|
||||||
"<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()
|
|
||||||
except (SecretFileError, ValueError):
|
except (SecretFileError, ValueError):
|
||||||
write_json_line(
|
write_json_line(
|
||||||
{
|
{
|
||||||
@@ -174,12 +133,15 @@ async def async_main() -> int:
|
|||||||
("rerank", probe_rerank),
|
("rerank", probe_rerank),
|
||||||
("chat", probe_chat),
|
("chat", probe_chat),
|
||||||
)
|
)
|
||||||
results = []
|
try:
|
||||||
for capability, operation in probes:
|
results = []
|
||||||
result = await run_probe(capability, operation, settings, api_key)
|
for capability, operation in probes:
|
||||||
results.append(result)
|
result = await run_probe(capability, operation, settings, adapter)
|
||||||
write_json_line(asdict(result))
|
results.append(result)
|
||||||
return 0 if all(result.status == "ok" for result in results) else 1
|
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:
|
def main() -> None:
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ from pgvector.psycopg import register_vector
|
|||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
from psycopg.types.json import Jsonb
|
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.fake import FakeEmbeddingProvider, FakeReranker, lexical_features
|
||||||
|
from app.adapters.model_gateway import ModelGatewayAdapter
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.core.demo_identity import (
|
from app.core.demo_identity import (
|
||||||
ACCESS_SCOPE_ID,
|
ACCESS_SCOPE_ID,
|
||||||
@@ -55,6 +55,42 @@ class DemoQuery:
|
|||||||
answerable: bool
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PreparedChunk:
|
class PreparedChunk:
|
||||||
source_id: str
|
source_id: str
|
||||||
@@ -71,6 +107,9 @@ class PreparedChunk:
|
|||||||
embedding_profile_hash: str
|
embedding_profile_hash: str
|
||||||
vector: tuple[float, ...]
|
vector: tuple[float, ...]
|
||||||
embedding_model: str
|
embedding_model: str
|
||||||
|
provider_request_id: str | None
|
||||||
|
embedding_usage: dict[str, int | None]
|
||||||
|
embedding_elapsed_ms: int
|
||||||
title: str
|
title: str
|
||||||
region: str
|
region: str
|
||||||
mineral: str
|
mineral: str
|
||||||
@@ -88,6 +127,14 @@ def sha256_text(value: str) -> str:
|
|||||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
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]]:
|
def load_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise SeedContractError("fixture_missing")
|
raise SeedContractError("fixture_missing")
|
||||||
@@ -142,8 +189,10 @@ def load_queries(path: Path) -> list[DemoQuery]:
|
|||||||
|
|
||||||
|
|
||||||
def embedding_profile_hash(settings: Settings, mode: str) -> str:
|
def embedding_profile_hash(settings: Settings, mode: str) -> str:
|
||||||
if mode != "bailian":
|
if mode == "fake":
|
||||||
return offline_embedding_profile_hash(settings.embedding_dimension)
|
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 "")
|
endpoint_identity = sha256_text(urlsplit(settings.bailian_openai_base_url).hostname or "")
|
||||||
model = settings.embedding_model
|
model = settings.embedding_model
|
||||||
@@ -164,15 +213,30 @@ def embedding_profile_hash(settings: Settings, mode: str) -> str:
|
|||||||
async def embed_in_batches(
|
async def embed_in_batches(
|
||||||
provider: EmbeddingProvider,
|
provider: EmbeddingProvider,
|
||||||
texts: Sequence[str],
|
texts: Sequence[str],
|
||||||
) -> tuple[tuple[tuple[float, ...], ...], str]:
|
) -> tuple[tuple[EmbeddedVector, ...], str]:
|
||||||
vectors: list[tuple[float, ...]] = []
|
vectors: list[EmbeddedVector] = []
|
||||||
resolved_model: str | None = None
|
resolved_model: str | None = None
|
||||||
for offset in range(0, len(texts), 10):
|
for offset in range(0, len(texts), 10):
|
||||||
result = await provider.embed_documents(texts[offset : offset + 10])
|
result = await provider.embed_documents(texts[offset : offset + 10])
|
||||||
if resolved_model is not None and result.model != resolved_model:
|
if resolved_model is not None and result.model != resolved_model:
|
||||||
raise SeedContractError("embedding_model_changed_between_batches")
|
raise SeedContractError("embedding_model_changed_between_batches")
|
||||||
resolved_model = result.model
|
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:
|
if len(vectors) != len(texts) or resolved_model is None:
|
||||||
raise SeedContractError("embedding_result_count_mismatch")
|
raise SeedContractError("embedding_result_count_mismatch")
|
||||||
return tuple(vectors), resolved_model
|
return tuple(vectors), resolved_model
|
||||||
@@ -180,10 +244,11 @@ async def embed_in_batches(
|
|||||||
|
|
||||||
def prepare_chunks(
|
def prepare_chunks(
|
||||||
documents: Sequence[DemoDocument],
|
documents: Sequence[DemoDocument],
|
||||||
vectors: Sequence[tuple[float, ...]],
|
vectors: Sequence[EmbeddedVector],
|
||||||
*,
|
*,
|
||||||
profile_hash: str,
|
profile_hash: str,
|
||||||
embedding_model: str,
|
embedding_model: str,
|
||||||
|
namespace: DemoNamespace = OFFLINE_NAMESPACE,
|
||||||
) -> list[PreparedChunk]:
|
) -> list[PreparedChunk]:
|
||||||
prepared = []
|
prepared = []
|
||||||
for document, vector in zip(documents, vectors, strict=True):
|
for document, vector in zip(documents, vectors, strict=True):
|
||||||
@@ -200,7 +265,12 @@ def prepare_chunks(
|
|||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
)
|
)
|
||||||
raw_hash = sha256_text(raw_payload)
|
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(
|
version_id = uuid.uuid5(
|
||||||
IDENTITY_NAMESPACE,
|
IDENTITY_NAMESPACE,
|
||||||
f"version:{document.source_id}:{raw_hash}:{profile_hash}",
|
f"version:{document.source_id}:{raw_hash}:{profile_hash}",
|
||||||
@@ -237,8 +307,11 @@ def prepare_chunks(
|
|||||||
embedding_text_sha256=embedding_hash,
|
embedding_text_sha256=embedding_hash,
|
||||||
outbound_manifest_sha256=sha256_text(manifest_payload),
|
outbound_manifest_sha256=sha256_text(manifest_payload),
|
||||||
embedding_profile_hash=profile_hash,
|
embedding_profile_hash=profile_hash,
|
||||||
vector=vector,
|
vector=vector.vector,
|
||||||
embedding_model=embedding_model,
|
embedding_model=embedding_model,
|
||||||
|
provider_request_id=vector.request_id,
|
||||||
|
embedding_usage=vector.usage,
|
||||||
|
embedding_elapsed_ms=vector.elapsed_ms,
|
||||||
title=document.title,
|
title=document.title,
|
||||||
region=document.region,
|
region=document.region,
|
||||||
mineral=document.mineral,
|
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:
|
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
|
||||||
register_vector(connection)
|
register_vector(connection)
|
||||||
connection.execute("SELECT pg_advisory_xact_lock(724202607120001)")
|
connection.execute("SELECT pg_advisory_xact_lock(724202607120001)")
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO rag.knowledge_bases (id, name, description)
|
INSERT INTO rag.model_profiles (
|
||||||
VALUES (%s, %s, %s)
|
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
|
ON CONFLICT (id) DO UPDATE
|
||||||
SET name = EXCLUDED.name,
|
SET name = EXCLUDED.name,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
|
active_embedding_profile_hash = EXCLUDED.active_embedding_profile_hash,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
""",
|
""",
|
||||||
(KNOWLEDGE_BASE_ID, "虚构地质 PoC 知识库", "仅含公开的合成验证文本"),
|
(
|
||||||
|
namespace.knowledge_base_id,
|
||||||
|
namespace.knowledge_base_name,
|
||||||
|
"仅含公开的合成验证文本",
|
||||||
|
profile_hash,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
@@ -276,7 +425,11 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
|||||||
VALUES (%s, %s, %s)
|
VALUES (%s, %s, %s)
|
||||||
ON CONFLICT (id) DO NOTHING
|
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:
|
for item in chunks:
|
||||||
@@ -295,11 +448,11 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
item.document_id,
|
item.document_id,
|
||||||
KNOWLEDGE_BASE_ID,
|
namespace.knowledge_base_id,
|
||||||
ACCESS_SCOPE_ID,
|
namespace.access_scope_id,
|
||||||
item.raw_sha256,
|
item.raw_sha256,
|
||||||
f"{item.source_id}.json",
|
f"{item.source_id}.json",
|
||||||
f"synthetic/{item.source_id}",
|
f"{namespace.storage_prefix}/{item.source_id}",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
@@ -384,10 +537,10 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
item.chunk_id,
|
item.chunk_id,
|
||||||
KNOWLEDGE_BASE_ID,
|
namespace.knowledge_base_id,
|
||||||
item.document_id,
|
item.document_id,
|
||||||
item.version_id,
|
item.version_id,
|
||||||
ACCESS_SCOPE_ID,
|
namespace.access_scope_id,
|
||||||
item.cloud_text,
|
item.cloud_text,
|
||||||
item.cloud_text,
|
item.cloud_text,
|
||||||
item.cloud_text_sha256,
|
item.cloud_text_sha256,
|
||||||
@@ -425,6 +578,41 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
|||||||
""",
|
""",
|
||||||
(item.version_id,),
|
(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(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE rag.chunks
|
UPDATE rag.chunks
|
||||||
@@ -459,8 +647,9 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
|||||||
count(*) FILTER (WHERE searchable)::integer AS searchable
|
count(*) FILTER (WHERE searchable)::integer AS searchable
|
||||||
FROM rag.chunks
|
FROM rag.chunks
|
||||||
WHERE knowledge_base_id = %s
|
WHERE knowledge_base_id = %s
|
||||||
|
AND embedding_profile_hash = %s
|
||||||
""",
|
""",
|
||||||
(KNOWLEDGE_BASE_ID,),
|
(namespace.knowledge_base_id, profile_hash),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if counts is None:
|
if counts is None:
|
||||||
raise SeedContractError("database_count_missing")
|
raise SeedContractError("database_count_missing")
|
||||||
@@ -470,6 +659,9 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
|
|||||||
def retrieve(
|
def retrieve(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
query_vector: tuple[float, ...],
|
query_vector: tuple[float, ...],
|
||||||
|
*,
|
||||||
|
namespace: DemoNamespace,
|
||||||
|
profile_hash: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
|
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
|
||||||
register_vector(connection)
|
register_vector(connection)
|
||||||
@@ -477,19 +669,25 @@ def retrieve(
|
|||||||
connection.execute("SET LOCAL hnsw.ef_search = 100")
|
connection.execute("SET LOCAL hnsw.ef_search = 100")
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, metadata, embedding_text,
|
SELECT chunk.id, chunk.metadata, chunk.embedding_text,
|
||||||
1 - (embedding <=> %s) AS vector_score
|
1 - (chunk.embedding <=> %s) AS vector_score
|
||||||
FROM rag.chunks
|
FROM rag.chunks AS chunk
|
||||||
WHERE searchable
|
JOIN rag.knowledge_bases AS knowledge_base
|
||||||
AND knowledge_base_id = %s
|
ON knowledge_base.id = chunk.knowledge_base_id
|
||||||
AND access_scope_id = %s
|
AND knowledge_base.active_embedding_profile_hash = %s
|
||||||
ORDER BY embedding <=> %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
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
Vector(list(query_vector)),
|
Vector(list(query_vector)),
|
||||||
KNOWLEDGE_BASE_ID,
|
profile_hash,
|
||||||
ACCESS_SCOPE_ID,
|
namespace.knowledge_base_id,
|
||||||
|
namespace.access_scope_id,
|
||||||
|
profile_hash,
|
||||||
Vector(list(query_vector)),
|
Vector(list(query_vector)),
|
||||||
settings.vector_top_k,
|
settings.vector_top_k,
|
||||||
),
|
),
|
||||||
@@ -502,12 +700,20 @@ async def evaluate_queries(
|
|||||||
queries: Sequence[DemoQuery],
|
queries: Sequence[DemoQuery],
|
||||||
embedder: EmbeddingProvider,
|
embedder: EmbeddingProvider,
|
||||||
reranker: Reranker,
|
reranker: Reranker,
|
||||||
|
*,
|
||||||
|
namespace: DemoNamespace,
|
||||||
|
profile_hash: str,
|
||||||
) -> dict[str, float | int]:
|
) -> dict[str, float | int]:
|
||||||
hits = 0
|
hits = 0
|
||||||
answerable = 0
|
answerable = 0
|
||||||
for query in queries:
|
for query in queries:
|
||||||
query_result = await embedder.embed_query(query.query)
|
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:
|
if not candidates:
|
||||||
continue
|
continue
|
||||||
reranked = await reranker.rerank(
|
reranked = await reranker.rerank(
|
||||||
@@ -552,14 +758,14 @@ async def async_main() -> int:
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
namespace = namespace_for_mode(mode)
|
||||||
documents_path = Path(
|
documents_path = Path(
|
||||||
os.getenv("DEMO_DOCUMENTS_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_documents.jsonl"))
|
os.getenv("DEMO_DOCUMENTS_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_documents.jsonl"))
|
||||||
)
|
)
|
||||||
queries_path = Path(
|
queries_path = Path(
|
||||||
os.getenv("DEMO_QUERIES_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_queries.jsonl"))
|
os.getenv("DEMO_QUERIES_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_queries.jsonl"))
|
||||||
)
|
)
|
||||||
cloud_embedder: BailianEmbeddingAdapter | None = None
|
cloud_gateway: ModelGatewayAdapter | None = None
|
||||||
cloud_reranker: BailianRerankerAdapter | None = None
|
|
||||||
try:
|
try:
|
||||||
documents = load_documents(documents_path)
|
documents = load_documents(documents_path)
|
||||||
queries = load_queries(queries_path)
|
queries = load_queries(queries_path)
|
||||||
@@ -567,24 +773,9 @@ async def async_main() -> int:
|
|||||||
embedder: EmbeddingProvider
|
embedder: EmbeddingProvider
|
||||||
reranker: Reranker
|
reranker: Reranker
|
||||||
if mode == "bailian":
|
if mode == "bailian":
|
||||||
api_key = settings.bailian_api_key()
|
cloud_gateway = ModelGatewayAdapter.from_settings(settings)
|
||||||
cloud_embedder = BailianEmbeddingAdapter(
|
embedder = cloud_gateway
|
||||||
api_key=api_key,
|
reranker = cloud_gateway
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
embedder = FakeEmbeddingProvider(settings.embedding_dimension)
|
embedder = FakeEmbeddingProvider(settings.embedding_dimension)
|
||||||
reranker = FakeReranker()
|
reranker = FakeReranker()
|
||||||
@@ -599,9 +790,17 @@ async def async_main() -> int:
|
|||||||
vectors,
|
vectors,
|
||||||
profile_hash=profile_hash,
|
profile_hash=profile_hash,
|
||||||
embedding_model=resolved_model,
|
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(
|
output_summary(
|
||||||
{
|
{
|
||||||
"counts": counts,
|
"counts": counts,
|
||||||
@@ -654,10 +853,8 @@ async def async_main() -> int:
|
|||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
if cloud_embedder is not None:
|
if cloud_gateway is not None:
|
||||||
await cloud_embedder.aclose()
|
await cloud_gateway.aclose()
|
||||||
if cloud_reranker is not None:
|
|
||||||
await cloud_reranker.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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")
|
db = _service_block("db")
|
||||||
migrate = _service_block("migrate")
|
migrate = _service_block("migrate")
|
||||||
api = _service_block("api")
|
api = _service_block("api")
|
||||||
|
model_gateway = _service_block("model-gateway")
|
||||||
gateway = _service_block("gateway")
|
gateway = _service_block("gateway")
|
||||||
web = _service_block("web")
|
web = _service_block("web")
|
||||||
provider_smoke = _service_block("provider-smoke")
|
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" not in migrate
|
||||||
|
|
||||||
assert "postgres_app_password" in api
|
assert "postgres_app_password" in api
|
||||||
|
assert "model_gateway_api_token" in api
|
||||||
assert "postgres_bootstrap_password" not in api
|
assert "postgres_bootstrap_password" not in api
|
||||||
assert "postgres_migrator_password" not in api
|
assert "postgres_migrator_password" not in api
|
||||||
assert "bailian_api_key" not in api
|
assert "bailian_api_key" not in api
|
||||||
assert '"127.0.0.1:8000:8000"' not in api
|
assert '"127.0.0.1:8000:8000"' not in api
|
||||||
assert " - data" in api
|
assert " - data" in api
|
||||||
|
assert " - model" in api
|
||||||
assert " - edge" not in api
|
assert " - edge" not in api
|
||||||
assert " - egress" not in api
|
assert " - egress" not in api
|
||||||
assert "read_only: true" in api
|
assert "read_only: true" in api
|
||||||
assert "no-new-privileges:true" in api
|
assert "no-new-privileges:true" in api
|
||||||
assert "cap_drop:" in api and " - ALL" 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 '"127.0.0.1:8000:8000"' not in gateway
|
||||||
assert " - ingress" in gateway
|
assert " - ingress" in gateway
|
||||||
assert " - data" in gateway
|
assert " - data" in gateway
|
||||||
|
assert " - model" not in gateway
|
||||||
assert " - edge" not in gateway
|
assert " - edge" not in gateway
|
||||||
assert " - egress" not in gateway
|
assert " - egress" not in gateway
|
||||||
assert "secrets:" 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 " - edge" in web
|
||||||
assert " - ingress" in web
|
assert " - ingress" in web
|
||||||
assert " - data" not in web
|
assert " - data" not in web
|
||||||
|
assert " - model" not in web
|
||||||
assert " - egress" not in web
|
assert " - egress" not in web
|
||||||
assert "secrets:" not in web
|
assert "secrets:" not in web
|
||||||
assert "POSTGRES_" 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 "no-new-privileges:true" in web
|
||||||
assert len(re.findall(r"(?m)^ ports:$", COMPOSE)) == 1
|
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 "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_app_password" in seed_demo
|
||||||
assert "postgres_bootstrap_password" not in seed_demo
|
assert "postgres_bootstrap_password" not in seed_demo
|
||||||
assert "postgres_migrator_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 "./data/samples/public:/demo:ro" in seed_demo
|
||||||
|
|
||||||
assert "postgres_app_password" in seed_demo_offline
|
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)^ data:\n.*?^ internal: true$", COMPOSE)
|
||||||
assert re.search(r"(?ms)^ ingress:\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)^ edge:$", COMPOSE)
|
||||||
assert re.search(r"(?m)^ egress:$", 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")
|
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:
|
def test_embedding_dimension_accepts_compose_string(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setenv("EMBEDDING_DIMENSION", "1024")
|
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
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.model_gateway import ModelGatewayAdapter
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
|
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
|
||||||
from app.tools import provider_smoke
|
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(
|
async def test_provider_smoke_entrypoint_accepts_compose_embedding_dimension(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
workspace_host = "workspace-test.cn-beijing.maas.aliyuncs.com"
|
|
||||||
monkeypatch.setenv("EMBEDDING_DIMENSION", "1024")
|
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] = []
|
observed_dimensions: list[int] = []
|
||||||
|
|
||||||
def fake_api_key(settings: Settings) -> str:
|
class StubGateway:
|
||||||
observed_dimensions.append(settings.embedding_dimension)
|
async def aclose(self) -> None:
|
||||||
return "test-only-api-key"
|
return None
|
||||||
|
|
||||||
async def successful_probe(settings: Settings, api_key: str) -> ProbeResult:
|
def fake_gateway(settings: Settings) -> StubGateway:
|
||||||
observed_dimensions.append(settings.embedding_dimension)
|
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")
|
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_embedding", successful_probe)
|
||||||
monkeypatch.setattr(provider_smoke, "probe_rerank", successful_probe)
|
monkeypatch.setattr(provider_smoke, "probe_rerank", successful_probe)
|
||||||
monkeypatch.setattr(provider_smoke, "probe_chat", successful_probe)
|
monkeypatch.setattr(provider_smoke, "probe_chat", successful_probe)
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import pytest
|
|||||||
from app.adapters.fake import FakeEmbeddingProvider
|
from app.adapters.fake import FakeEmbeddingProvider
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.tools.seed_demo import (
|
from app.tools.seed_demo import (
|
||||||
|
BAILIAN_NAMESPACE,
|
||||||
|
OFFLINE_NAMESPACE,
|
||||||
embed_in_batches,
|
embed_in_batches,
|
||||||
embedding_profile_hash,
|
embedding_profile_hash,
|
||||||
load_documents,
|
load_documents,
|
||||||
|
namespace_for_mode,
|
||||||
prepare_chunks,
|
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 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 len({item.chunk_id for item in chunks}) == 20
|
||||||
assert all(len(item.outbound_manifest_sha256) == 64 for item in chunks)
|
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:
|
def test_seed_rejects_fixture_not_explicitly_marked_synthetic(tmp_path: Path) -> None:
|
||||||
|
|||||||
81
compose.yaml
81
compose.yaml
@@ -31,6 +31,12 @@ x-rag-config: &rag-config
|
|||||||
MODEL_MAX_RETRIES: "${MODEL_MAX_RETRIES:-3}"
|
MODEL_MAX_RETRIES: "${MODEL_MAX_RETRIES:-3}"
|
||||||
MODEL_MAX_CONCURRENCY: "${MODEL_MAX_CONCURRENCY:-4}"
|
MODEL_MAX_CONCURRENCY: "${MODEL_MAX_CONCURRENCY:-4}"
|
||||||
|
|
||||||
|
x-model-client-config: &model-client-config
|
||||||
|
MODEL_GATEWAY_BASE_URL: http://model-gateway:8000
|
||||||
|
MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_api_token
|
||||||
|
MODEL_GATEWAY_CALLER: api
|
||||||
|
MODEL_GATEWAY_TIMEOUT_SECONDS: "${MODEL_GATEWAY_TIMEOUT_SECONDS:-120}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: pgvector/pgvector:0.8.2-pg17@sha256:feb68f4f15446397d8cac7f4fe48fe4586de83160d1fc48b46283312d1a33966
|
image: pgvector/pgvector:0.8.2-pg17@sha256:feb68f4f15446397d8cac7f4fe48fe4586de83160d1fc48b46283312d1a33966
|
||||||
@@ -90,11 +96,53 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
environment: *runtime-config
|
model-gateway:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
<<: [*runtime-config, *model-client-config]
|
||||||
secrets:
|
secrets:
|
||||||
- postgres_app_password
|
- postgres_app_password
|
||||||
|
- model_gateway_api_token
|
||||||
networks:
|
networks:
|
||||||
- data
|
- data
|
||||||
|
- model
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- >-
|
||||||
|
import urllib.request;
|
||||||
|
urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=2)
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
init: true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
model-gateway:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
command: ["uvicorn", "app.model_gateway:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
environment:
|
||||||
|
<<: *rag-config
|
||||||
|
MODEL_GATEWAY_ALLOWED_TOKEN_FILES: >-
|
||||||
|
api=/run/secrets/model_gateway_api_token,worker=/run/secrets/model_gateway_worker_token
|
||||||
|
secrets:
|
||||||
|
- bailian_api_key
|
||||||
|
- model_gateway_api_token
|
||||||
|
- model_gateway_worker_token
|
||||||
|
networks:
|
||||||
|
- model
|
||||||
|
- egress
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
@@ -187,11 +235,19 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
command: ["python", "-m", "app.tools.provider_smoke"]
|
command: ["python", "-m", "app.tools.provider_smoke"]
|
||||||
profiles: ["tools"]
|
profiles: ["tools"]
|
||||||
environment: *rag-config
|
depends_on:
|
||||||
|
model-gateway:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
<<: *model-client-config
|
||||||
|
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-text-embedding-v4}
|
||||||
|
EMBEDDING_DIMENSION: "${EMBEDDING_DIMENSION:-1024}"
|
||||||
|
RERANK_MODEL: ${RERANK_MODEL:-qwen3-rerank}
|
||||||
|
LLM_MODEL: ${LLM_MODEL:-deepseek-v4-flash}
|
||||||
secrets:
|
secrets:
|
||||||
- bailian_api_key
|
- model_gateway_api_token
|
||||||
networks:
|
networks:
|
||||||
- egress
|
- model
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
seed-demo:
|
seed-demo:
|
||||||
@@ -202,17 +258,21 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
model-gateway:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
<<: [*runtime-config, *rag-config]
|
<<: [*runtime-config, *rag-config, *model-client-config]
|
||||||
DEMO_PROVIDER_MODE: bailian
|
DEMO_PROVIDER_MODE: bailian
|
||||||
DEMO_DOCUMENTS_PATH: /demo/demo_documents.jsonl
|
DEMO_DOCUMENTS_PATH: /demo/demo_documents.jsonl
|
||||||
DEMO_QUERIES_PATH: /demo/demo_queries.jsonl
|
DEMO_QUERIES_PATH: /demo/demo_queries.jsonl
|
||||||
|
MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_worker_token
|
||||||
|
MODEL_GATEWAY_CALLER: worker
|
||||||
secrets:
|
secrets:
|
||||||
- postgres_app_password
|
- postgres_app_password
|
||||||
- bailian_api_key
|
- model_gateway_worker_token
|
||||||
networks:
|
networks:
|
||||||
- data
|
- data
|
||||||
- egress
|
- model
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/samples/public:/demo:ro
|
- ./data/samples/public:/demo:ro
|
||||||
restart: "no"
|
restart: "no"
|
||||||
@@ -250,6 +310,9 @@ networks:
|
|||||||
data:
|
data:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
internal: true
|
internal: true
|
||||||
|
model:
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
egress:
|
egress:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
@@ -260,5 +323,9 @@ secrets:
|
|||||||
file: ./secrets/postgres_migrator_password
|
file: ./secrets/postgres_migrator_password
|
||||||
postgres_app_password:
|
postgres_app_password:
|
||||||
file: ./secrets/postgres_app_password
|
file: ./secrets/postgres_app_password
|
||||||
|
model_gateway_api_token:
|
||||||
|
file: ./secrets/model_gateway_api_token
|
||||||
|
model_gateway_worker_token:
|
||||||
|
file: ./secrets/model_gateway_worker_token
|
||||||
bailian_api_key:
|
bailian_api_key:
|
||||||
file: ./secrets/bailian_api_key
|
file: ./secrets/bailian_api_key
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 课题 | 基于 RAG 的地质找矿知识问答系统构建与应用 |
|
| 课题 | 基于 RAG 的地质找矿知识问答系统构建与应用 |
|
||||||
| 学科方向 | 大数据分析 |
|
| 学科方向 | 大数据分析 |
|
||||||
| 文档版本 | v1.0-design |
|
| 文档版本 | v1.1-implementation-sync |
|
||||||
| 状态 | 设计基线,待实现验证 |
|
| 状态 | 设计基线;安全运行骨架已部分实现,产品主链路未完成 |
|
||||||
| 更新日期 | 2026-07-11 |
|
| 更新日期 | 2026-07-13 |
|
||||||
| 后端 | Python + FastAPI |
|
| 后端 | Python + FastAPI |
|
||||||
| 前端 | React + TypeScript |
|
| 前端 | React + TypeScript |
|
||||||
| 向量存储 | PostgreSQL + pgvector |
|
| 向量存储 | PostgreSQL + pgvector |
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
1. 仓库、提交历史、镜像层、前端包、测试数据和日志都不能出现真实 Key。
|
1. 仓库、提交历史、镜像层、前端包、测试数据和日志都不能出现真实 Key。
|
||||||
2. 开发环境从未提交的 Docker Secret 文件读取;生产环境从 Secret Manager 或编排平台读取。
|
2. 开发环境从未提交的 Docker Secret 文件读取;生产环境从 Secret Manager 或编排平台读取。
|
||||||
3. 前端永远不接触 Key,所有百炼请求由后端发起。
|
3. 前端、入口 Gateway、业务 API、Worker、seed 和 smoke 工具永远不接触百炼 Key;所有百炼请求只由独立 `model-gateway` 发起。
|
||||||
4. 日志不得记录 `Authorization` 请求头或完整模型请求体。
|
4. 日志不得记录 `Authorization` 请求头或完整模型请求体。
|
||||||
5. 工作空间真实域名也只进入本地部署配置;文档统一使用 `<workspace-id>`。
|
5. 工作空间真实域名也只进入本地部署配置;文档统一使用 `<workspace-id>`。
|
||||||
6. 每次提交前执行 `make verify`;仓库已配置本地钩子和 Gitea Actions 双重检查,Actions 需以远端 runner 首次成功记录作为生效证据。
|
6. 每次提交前执行 `make verify`;仓库已配置本地钩子和 Gitea Actions 双重检查,Actions 需以远端 runner 首次成功记录作为生效证据。
|
||||||
@@ -33,11 +33,15 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
React/Nginx
|
React/Nginx
|
||||||
|
-> 无 Secret 入口 Gateway
|
||||||
-> FastAPI API
|
-> FastAPI API
|
||||||
-> PostgreSQL + pgvector
|
-> PostgreSQL + pgvector
|
||||||
-> 阿里云百炼 Embedding / Rerank / Chat
|
-> 内部 model-gateway client
|
||||||
-> FastAPI Worker(与 API 共用同一 Python 代码镜像)
|
-> Python Worker(与 API 共用同一 Python 代码镜像)
|
||||||
-> 文档解析、分块、向量化和持久化任务
|
-> 文档解析、分块、向量化和持久化任务
|
||||||
|
-> 内部 model-gateway client
|
||||||
|
model-gateway(唯一持有百炼 Key 和公网出口)
|
||||||
|
-> 阿里云百炼 Embedding / Rerank / Chat
|
||||||
```
|
```
|
||||||
|
|
||||||
默认模型链:
|
默认模型链:
|
||||||
@@ -51,7 +55,7 @@ deepseek-v4-flash
|
|||||||
-> 只依据证据回答并给出页码引用
|
-> 只依据证据回答并给出页码引用
|
||||||
```
|
```
|
||||||
|
|
||||||
选择 PostgreSQL + pgvector 的原因是当前预计 1 万至 30 万切片,单机 PostgreSQL 已足够,并能把业务状态、元数据、向量和后台任务放在一个事务边界中。这样核心长期运行组件只有 Web、API、Worker、数据库,一条 `docker compose up -d --build` 即可启动。达到百万级切片或高吞吐边界后再评估 Qdrant,而不是在第一版提前承担双写一致性和第二套备份系统。
|
选择 PostgreSQL + pgvector 的原因是当前预计 1 万至 30 万切片,单机 PostgreSQL 已足够,并能把业务状态、元数据、向量和后台任务放在一个事务边界中。为把数据库权限与云凭证/公网出口分开,运行拓扑额外保留一个很小的 `model-gateway` 安全边界;它复用后端镜像和领域端口,不把业务拆成多套数据库或分布式事务。达到百万级切片或高吞吐边界后再评估 Qdrant,而不是在第一版提前承担双写一致性和第二套备份系统。
|
||||||
|
|
||||||
## 2. 项目目标和边界
|
## 2. 项目目标和边界
|
||||||
|
|
||||||
@@ -136,13 +140,14 @@ flowchart LR
|
|||||||
G -->|"internal data"| A["FastAPI API"]
|
G -->|"internal data"| A["FastAPI API"]
|
||||||
A --> D[("PostgreSQL + pgvector")]
|
A --> D[("PostgreSQL + pgvector")]
|
||||||
A --> F[("文件卷 / OSS 适配器")]
|
A --> F[("文件卷 / OSS 适配器")]
|
||||||
A --> E["text-embedding-v4"]
|
A -->|"Bearer + X-RAG-Caller: api"| M["内部 model-gateway"]
|
||||||
A --> R["qwen3-rerank"]
|
M --> E["text-embedding-v4"]
|
||||||
A --> L["deepseek-v4-flash"]
|
M --> R["qwen3-rerank"]
|
||||||
|
M --> L["deepseek-v4-flash"]
|
||||||
J["DB 持久化任务"] --> K["Python Worker"]
|
J["DB 持久化任务"] --> K["Python Worker"]
|
||||||
K --> D
|
K --> D
|
||||||
K --> F
|
K --> F
|
||||||
K --> E
|
K -->|"Bearer + X-RAG-Caller: worker"| M
|
||||||
A --> J
|
A --> J
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -154,13 +159,14 @@ flowchart LR
|
|||||||
| `gateway` | 固定 API 上游、请求大小/头边界、脱敏错误和流式转发 | 数据库凭证、模型凭证、业务权限判断 |
|
| `gateway` | 固定 API 上游、请求大小/头边界、脱敏错误和流式转发 | 数据库凭证、模型凭证、业务权限判断 |
|
||||||
| `api` | 认证、知识库、检索、问答、评测 API | 长时间 OCR/批量入库 |
|
| `api` | 认证、知识库、检索、问答、评测 API | 长时间 OCR/批量入库 |
|
||||||
| `worker` | 解析、OCR、分块、向量化、索引、评测批任务 | 对外开放端口 |
|
| `worker` | 解析、OCR、分块、向量化、索引、评测批任务 | 对外开放端口 |
|
||||||
|
| `model-gateway` | 唯一读取百炼 Key、统一模型协议/限流/脱敏错误;校验 API/Worker 内部身份 | 数据库、上传卷、外部端口、业务授权 |
|
||||||
| `db` | 元数据、向量、任务、会话、评测事实来源 | 原始大文件长期存储 |
|
| `db` | 元数据、向量、任务、会话、评测事实来源 | 原始大文件长期存储 |
|
||||||
| `storage` | 开发期 Docker volume,生产可切换 OSS | 访问控制的最终判定 |
|
| `storage` | 开发期 Docker volume,生产可切换 OSS | 访问控制的最终判定 |
|
||||||
| 百炼适配器 | 统一超时、重试、计量、错误映射 | 记录密钥或正文日志 |
|
| 百炼适配器 | 仅在 `model-gateway` 进程内统一超时、重试、计量、错误映射 | 记录密钥或正文日志 |
|
||||||
|
|
||||||
### 4.3 为什么不先拆微服务
|
### 4.3 为什么不先拆微服务
|
||||||
|
|
||||||
解析、检索、生成和评测共享同一领域模型、配置和数据库。拆分微服务会提前引入服务发现、分布式追踪、数据一致性和接口版本问题,却没有对应吞吐收益。第一版使用一个 Python 包,由 `api` 和 `worker` 两个进程加载;当某个模块具有独立扩容证据时再拆分。
|
解析、检索、生成和评测共享同一领域模型、配置和数据库。拆分业务微服务会提前引入服务发现、分布式追踪、数据一致性和接口版本问题,却没有对应吞吐收益。第一版使用一个 Python 包,由 `api`、`worker` 和 `model-gateway` 三种进程加载;其中 `model-gateway` 不是独立业务服务,而是最小权限的凭证/出口隔离边界。详细决策见 [ADR-0005](adr/0005-isolate-model-egress.md)。
|
||||||
|
|
||||||
## 5. 技术选型
|
## 5. 技术选型
|
||||||
|
|
||||||
@@ -209,7 +215,7 @@ ADR 见 [ADR-0001](adr/0001-use-pgvector.md)。
|
|||||||
|
|
||||||
### 6.1 端点必须分开
|
### 6.1 端点必须分开
|
||||||
|
|
||||||
业务空间专属域名的 Key、地域和 Base URL 必须匹配;官方推荐生产使用专属域名,见[Base URL 总览](https://help.aliyun.com/zh/model-studio/base-url)。配置只保存以下形态:
|
业务空间专属域名的 Key、地域和 Base URL 必须匹配;官方推荐生产使用专属域名,见[Base URL 总览](https://help.aliyun.com/zh/model-studio/base-url)。这些配置和 Key 只注入 `model-gateway`:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
BAILIAN_OPENAI_BASE_URL=https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
|
BAILIAN_OPENAI_BASE_URL=https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
|
||||||
@@ -227,6 +233,17 @@ DASHSCOPE_API_KEY_FILE=/run/secrets/bailian_api_key
|
|||||||
|
|
||||||
`/apps/anthropic` 是 Anthropic 协议入口,本项目不使用。尤其不能把 `/reranks` 拼到 `/compatible-mode/v1` 后面;官方的 Rerank 路径是独立的 `/compatible-api/v1/reranks`。
|
`/apps/anthropic` 是 Anthropic 协议入口,本项目不使用。尤其不能把 `/reranks` 拼到 `/compatible-mode/v1` 后面;官方的 Rerank 路径是独立的 `/compatible-api/v1/reranks`。
|
||||||
|
|
||||||
|
业务进程不接受任意模型名、供应商 URL 或向量维度,而只调用固定内部接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /internal/v1/embeddings
|
||||||
|
POST /internal/v1/rerank
|
||||||
|
POST /internal/v1/chat/completions
|
||||||
|
POST /internal/v1/chat/stream
|
||||||
|
```
|
||||||
|
|
||||||
|
调用必须同时携带 `Authorization: Bearer <internal-token>` 和 `X-RAG-Caller: api|worker`。服务端以常量时间比较 token 并要求 token 身份与 caller 一致;`api` 只允许查询向量、重排和聊天,文档向量化只允许 `worker` 身份。两个内部 token 相互独立、只通过 Docker Secret 注入。API、seed 和 smoke 工具均经内部客户端访问,既不挂载百炼 Key,也不加入公网 `egress` 网络。
|
||||||
|
|
||||||
### 6.2 Embedding 契约
|
### 6.2 Embedding 契约
|
||||||
|
|
||||||
基线请求:
|
基线请求:
|
||||||
@@ -365,6 +382,10 @@ UPLOADED
|
|||||||
| `evaluation_sets/cases` | 版本化问题、证据和标签 |
|
| `evaluation_sets/cases` | 版本化问题、证据和标签 |
|
||||||
| `evaluation_runs/results` | 参数快照、指标、延迟、成本和错误 |
|
| `evaluation_runs/results` | 参数快照、指标、延迟、成本和错误 |
|
||||||
| `audit_logs` | 管理操作和安全审计 |
|
| `audit_logs` | 管理操作和安全审计 |
|
||||||
|
| `model_profiles` | 模型种类、模型名、API 模式、1024 维约束、端点身份哈希和无凭证配置快照;受控 cache epoch 必须进入不可变 profile hash |
|
||||||
|
| `embedding_cache` | 以 `profile_hash + embedding_text_sha256` 唯一缓存 1024 维向量和脱敏调用元数据 |
|
||||||
|
| `chunk_embedding_assignments` | chunk/profile 到缓存项的状态化绑定,保证 READY 只指向同文本同 profile 向量 |
|
||||||
|
| `model_invocations` | 只记录 trace、caller、模型、用量、耗时、request ID 和脱敏错误,不保存请求/响应正文 |
|
||||||
|
|
||||||
### 8.2 核心向量表(示意)
|
### 8.2 核心向量表(示意)
|
||||||
|
|
||||||
@@ -441,6 +462,8 @@ COMMIT;
|
|||||||
- 删除先清空 `active_version_id` 并令投影不可检索,再异步物理删除文件和向量;
|
- 删除先清空 `active_version_id` 并令投影不可检索,再异步物理删除文件和向量;
|
||||||
- 任何回答保留当时的 chunk ID、模型和 Prompt 版本,保证复现。
|
- 任何回答保留当时的 chunk ID、模型和 Prompt 版本,保证复现。
|
||||||
|
|
||||||
|
Alembic `0002_model_profiles` 已实现上述 profile、缓存、assignment 和调用审计表,为 `knowledge_bases` 增加激活 Embedding profile,并为 `chunks` 增加稳定唯一的 `citation_id`。迁移只会为可明确识别的单一 synthetic fake profile 做安全回填,绝不从模型别名或 URL 猜测真实供应商身份;同一知识库存在多个候选 profile 时保持未激活,等待显式治理。已在独立全新 volume 验证 `空库 -> 0001 -> 0002 -> 0001 -> 0002`,并在已有 20 条合成向量的数据卷完成相同升降级;重复 seed 后 profile/active KB 为 1/1,cache/READY assignment/citation 均为 20,且 citation 无重复。
|
||||||
|
|
||||||
## 9. 在线 RAG 流程
|
## 9. 在线 RAG 流程
|
||||||
|
|
||||||
### 9.1 主流程
|
### 9.1 主流程
|
||||||
@@ -527,6 +550,8 @@ Rerank 只能重新排序已召回候选,无法找回初召回遗漏的证据
|
|||||||
|
|
||||||
## 10. 后端 API 设计
|
## 10. 后端 API 设计
|
||||||
|
|
||||||
|
FastAPI 入口通过 `create_app()` 应用工厂构建,导入阶段不打开数据库、不读取百炼凭证。HTTP 请求由统一中间件生成或透传安全的 trace ID;业务错误使用稳定的 Problem JSON 响应并回传 trace,未知异常不得泄漏连接串、Secret 路径或供应商正文。`/health/live` 只证明进程存活,`/health/ready` 只验证本地数据库依赖;百炼能力状态由独立模型探测展示,避免供应商短时故障触发 API 重启风暴。
|
||||||
|
|
||||||
统一前缀 `/api/v1`:
|
统一前缀 `/api/v1`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -703,6 +728,10 @@ pgvector 官方给出的单精度 `vector` 存储约为 `4 * dimensions + 8` 字
|
|||||||
|
|
||||||
详细指标与阶段完成定义分别见 [01-data-and-evaluation.md](01-data-and-evaluation.md) 和 [03-implementation-plan.md](03-implementation-plan.md)。
|
详细指标与阶段完成定义分别见 [01-data-and-evaluation.md](01-data-and-evaluation.md) 和 [03-implementation-plan.md](03-implementation-plan.md)。
|
||||||
|
|
||||||
|
### 15.1 2026-07-13 实现边界
|
||||||
|
|
||||||
|
当前已实现安全运行骨架、内部 `model-gateway` 及 token 身份、模型内部客户端、应用工厂/Problem/trace 契约和 `0002_model_profiles` 迁移代码;`provider-smoke`、真实 `seed-demo` 和 API 进程不再直接持有百炼 Key。当前工作空间对 `text-embedding-v4`、`qwen3-rerank`、`deepseek-v4-flash` 的实际请求仍返回 401,因此三能力真实可用性尚未验收。数字文档上传/解析/审核、正式检索 API、grounded chat、Worker 租约恢复、评测运行器和完整产品 UI 仍是后续实现项;不得把本节理解为“整个项目完成”。
|
||||||
|
|
||||||
## 16. 参考资料
|
## 16. 参考资料
|
||||||
|
|
||||||
1. [阿里云百炼 Base URL 总览](https://help.aliyun.com/zh/model-studio/base-url)
|
1. [阿里云百炼 Base URL 总览](https://help.aliyun.com/zh/model-studio/base-url)
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ embedding_cache_key = SHA256(
|
|||||||
chunk_embedding_assignment = UNIQUE(chunk_id, embedding_profile_hash)
|
chunk_embedding_assignment = UNIQUE(chunk_id, embedding_profile_hash)
|
||||||
```
|
```
|
||||||
|
|
||||||
相同文本可以安全复用向量缓存,但每个 chunk 都必须建立独立 assignment,不能用缓存键替代任务/行唯一键。模型别名无法解析到稳定 revision 时,每次冻结实验显式设置 `cache_epoch`,避免别名漂移后复用旧向量。激活前再次核对当前 `embedding_text_sha256`、完整 profile 与 assignment;任何一项变化都生成新索引版本。
|
相同文本可以安全复用向量缓存,但每个 chunk 都必须建立独立 assignment,不能用缓存键替代任务/行唯一键。模型别名无法解析到稳定 revision 时,每次冻结实验显式设置 `cache_epoch`,并把它纳入不可变的 `embedding_profile_hash`;epoch 变化必须创建新 profile,禁止原地修改 profile 后继续复用旧缓存。激活前再次核对当前 `embedding_text_sha256`、完整 profile 与 assignment;任何一项变化都生成新索引版本。
|
||||||
|
|
||||||
`endpoint_identity_hash` 由规范化后的工作空间/部署端点身份计算,仅保存哈希而不在实验导出中暴露真实工作空间。这样同地域、同模型别名但不同 MaaS 工作空间的向量不会错误共用缓存。
|
`endpoint_identity_hash` 由规范化后的工作空间/部署端点身份计算,仅保存哈希而不在实验导出中暴露真实工作空间。这样同地域、同模型别名但不同 MaaS 工作空间的向量不会错误共用缓存。
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
| 项目 | 设计值 |
|
| 项目 | 设计值 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 部署方式 | Docker Compose 单机 |
|
| 部署方式 | Docker Compose 单机 |
|
||||||
| 长期服务 | `web`、`api`、`worker`、`db` |
|
| 长期服务 | 当前 `web`、`gateway`、`api`、`model-gateway`、`db`;后续加入 `worker` |
|
||||||
| 一次性服务 | `migrate` |
|
| 一次性服务 | `migrate` |
|
||||||
| 对外端口 | 仅 Nginx/Web |
|
| 对外端口 | 仅 Nginx/Web |
|
||||||
| 密钥 | Docker Secret / Secret Manager |
|
| 密钥 | Docker Secret / Secret Manager |
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
这条命令应完成数据库健康等待、迁移、Web/API/Worker 启动。它不等于“所有数据和模型凭证自动生成”,首次部署仍必须完成密钥轮换、Secret 创建和三模型能力探测。
|
这条命令当前完成数据库健康等待、一次性迁移、`model-gateway`、API、入口 Gateway 和 Web 启动;Worker 落地后也遵循同一依赖链。它不等于“所有数据和模型凭证自动生成”,首次部署仍必须完成密钥轮换、Secret 创建和三模型能力探测。
|
||||||
|
|
||||||
架构定位是适合毕设、演示和单机内部使用的生产式单节点,不宣称跨主机高可用。互联网生产环境应替换托管数据库、对象存储和集中密钥管理。
|
架构定位是适合毕设、演示和单机内部使用的生产式单节点,不宣称跨主机高可用。互联网生产环境应替换托管数据库、对象存储和集中密钥管理。
|
||||||
|
|
||||||
@@ -29,8 +29,9 @@ docker compose up -d --build
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `db` | 固定版本 pgvector 镜像 | 元数据、向量、任务、会话、评测 | 仅内部网络 |
|
| `db` | 固定版本 pgvector 镜像 | 元数据、向量、任务、会话、评测 | 仅内部网络 |
|
||||||
| `migrate` | 与后端同镜像 | 运行 Alembic,一次成功退出 | 无 |
|
| `migrate` | 与后端同镜像 | 运行 Alembic,一次成功退出 | 无 |
|
||||||
| `api` | `backend/Dockerfile` | FastAPI、检索、问答、管理 API | 仅 internal data 网络 |
|
| `api` | `backend/Dockerfile` | FastAPI、检索、问答、管理 API | 仅 internal data/model 网络 |
|
||||||
| `gateway` | 与后端同镜像 | 固定上游、请求边界、脱敏错误和流式转发 | 仅 internal ingress/data 网络 |
|
| `gateway` | 与后端同镜像 | 固定上游、请求边界、脱敏错误和流式转发 | 仅 internal ingress/data 网络 |
|
||||||
|
| `model-gateway` | 与后端同镜像 | 唯一读取百炼 Key,代理 Embedding/Rerank/Chat | 仅 internal model + egress 网络,无宿主机端口 |
|
||||||
| `worker` | 同一后端镜像 | 解析、向量化、评测后台任务 | 无 |
|
| `worker` | 同一后端镜像 | 解析、向量化、评测后台任务 | 无 |
|
||||||
| `web` | `frontend/Dockerfile` | React 静态资源、Nginx 同源入口和 SSE 代理 | `127.0.0.1:8000`;生产部署 HTTPS |
|
| `web` | `frontend/Dockerfile` | React 静态资源、Nginx 同源入口和 SSE 代理 | `127.0.0.1:8000`;生产部署 HTTPS |
|
||||||
| `ocr-worker` | 可选 profile | PaddleOCR 重型任务 | 无 |
|
| `ocr-worker` | 可选 profile | PaddleOCR 重型任务 | 无 |
|
||||||
@@ -72,6 +73,11 @@ x-rag-config: &rag-config
|
|||||||
MODEL_MAX_RETRIES: "${MODEL_MAX_RETRIES:-3}"
|
MODEL_MAX_RETRIES: "${MODEL_MAX_RETRIES:-3}"
|
||||||
MODEL_MAX_CONCURRENCY: "${MODEL_MAX_CONCURRENCY:-4}"
|
MODEL_MAX_CONCURRENCY: "${MODEL_MAX_CONCURRENCY:-4}"
|
||||||
|
|
||||||
|
x-model-client-config: &model-client-config
|
||||||
|
MODEL_GATEWAY_BASE_URL: http://model-gateway:8000
|
||||||
|
MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_api_token
|
||||||
|
MODEL_GATEWAY_CALLER: api
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: pgvector/pgvector:0.8.2-pg17
|
image: pgvector/pgvector:0.8.2-pg17
|
||||||
@@ -115,15 +121,35 @@ services:
|
|||||||
networks: [data]
|
networks: [data]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
|
model-gateway:
|
||||||
|
build: ./backend
|
||||||
|
command: ["uvicorn", "app.model_gateway:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
environment:
|
||||||
|
<<: *rag-config
|
||||||
|
MODEL_GATEWAY_ALLOWED_TOKEN_FILES: >-
|
||||||
|
api=/run/secrets/model_gateway_api_token,worker=/run/secrets/model_gateway_worker_token
|
||||||
|
secrets:
|
||||||
|
- bailian_api_key
|
||||||
|
- model_gateway_api_token
|
||||||
|
- model_gateway_worker_token
|
||||||
|
networks: [model, egress]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=2)"]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
environment: *runtime-config
|
model-gateway:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
<<: [*runtime-config, *model-client-config]
|
||||||
secrets:
|
secrets:
|
||||||
- postgres_app_password
|
- postgres_app_password
|
||||||
|
- model_gateway_api_token
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/data/uploads
|
- uploads:/data/uploads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -132,7 +158,7 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 12
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks: [data]
|
networks: [data, model]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
@@ -151,24 +177,29 @@ services:
|
|||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
environment:
|
environment:
|
||||||
<<: [*runtime-config, *rag-config]
|
<<: [*runtime-config, *model-client-config]
|
||||||
|
MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_worker_token
|
||||||
|
MODEL_GATEWAY_CALLER: worker
|
||||||
WORKER_CAPABILITIES: ${WORKER_CAPABILITIES:-document_parse,embedding,rerank,evaluation}
|
WORKER_CAPABILITIES: ${WORKER_CAPABILITIES:-document_parse,embedding,rerank,evaluation}
|
||||||
secrets:
|
secrets:
|
||||||
- postgres_app_password
|
- postgres_app_password
|
||||||
- bailian_api_key
|
- model_gateway_worker_token
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/data/uploads
|
- uploads:/data/uploads
|
||||||
networks: [data, egress]
|
networks: [data, model]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
provider-smoke:
|
provider-smoke:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
command: ["python", "-m", "app.tools.provider_smoke"]
|
command: ["python", "-m", "app.tools.provider_smoke"]
|
||||||
profiles: ["tools"]
|
profiles: ["tools"]
|
||||||
environment: *rag-config
|
depends_on:
|
||||||
|
model-gateway:
|
||||||
|
condition: service_healthy
|
||||||
|
environment: *model-client-config
|
||||||
secrets:
|
secrets:
|
||||||
- bailian_api_key
|
- model_gateway_api_token
|
||||||
networks: [egress]
|
networks: [model]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
seed-demo:
|
seed-demo:
|
||||||
@@ -179,11 +210,13 @@ services:
|
|||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
environment:
|
environment:
|
||||||
<<: [*runtime-config, *rag-config]
|
<<: [*runtime-config, *model-client-config]
|
||||||
|
MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_worker_token
|
||||||
|
MODEL_GATEWAY_CALLER: worker
|
||||||
secrets:
|
secrets:
|
||||||
- postgres_app_password
|
- postgres_app_password
|
||||||
- bailian_api_key
|
- model_gateway_worker_token
|
||||||
networks: [data, egress]
|
networks: [data, model]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
@@ -209,6 +242,9 @@ networks:
|
|||||||
data:
|
data:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
internal: true
|
internal: true
|
||||||
|
model:
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
egress:
|
egress:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
@@ -219,13 +255,17 @@ secrets:
|
|||||||
file: ./secrets/postgres_migrator_password
|
file: ./secrets/postgres_migrator_password
|
||||||
postgres_app_password:
|
postgres_app_password:
|
||||||
file: ./secrets/postgres_app_password
|
file: ./secrets/postgres_app_password
|
||||||
|
model_gateway_api_token:
|
||||||
|
file: ./secrets/model_gateway_api_token
|
||||||
|
model_gateway_worker_token:
|
||||||
|
file: ./secrets/model_gateway_worker_token
|
||||||
bailian_api_key:
|
bailian_api_key:
|
||||||
file: ./secrets/bailian_api_key
|
file: ./secrets/bailian_api_key
|
||||||
```
|
```
|
||||||
|
|
||||||
最终实现固定镜像 digest,示意版本只说明可行结构。`migrate` 只有一个实例执行;API 和 Worker 不在启动时并发自动迁移。首次初始化脚本只在 `db` 容器内使用 bootstrap 凭据创建 `vector` 扩展、无超级用户权限的 migrator/app 登录角色和专属 schema;migrator 拥有 schema/DDL,app 仅获运行期所需的 DML/sequence 权限及默认权限,API/Worker 永远不挂载 bootstrap 或 migrator Secret。最后一个初始化脚本只有在全部 SQL 事务和授权成功后,才在 `PGDATA` 内用同文件系统 rename 原子写入 `.rag-bootstrap-complete`;healthcheck 同时检查 `pg_isready` 与该哨兵,防止临时初始化 server 提前放行迁移。备份再使用独立只读角色。Compose 的 `.env` 仅做变量插值,不会自动注入容器,因此可配置的非敏感项和 `*_FILE` 路径通过 YAML anchor 在 `environment` 中完整声明。后端从各自的 `POSTGRES_PASSWORD_FILE` 读取密码后在内存中组装 DSN,不把明文密码放进 `.env` 或 `DATABASE_URL`。
|
实际实现固定数据库镜像 digest。`migrate` 只有一个实例执行;成功应用到 head 后退出码为 0,随后在 `docker compose ps -a` 显示 `Exited (0)` 是一次性任务的正常终态,不是服务暂停。API 和 Worker 不在启动时并发自动迁移。首次初始化脚本只在 `db` 容器内使用 bootstrap 凭据创建 `vector` 扩展、无超级用户权限的 migrator/app 登录角色和专属 schema;migrator 拥有 schema/DDL,app 仅获运行期所需的 DML/sequence 权限及默认权限,API/Worker 永远不挂载 bootstrap 或 migrator Secret。最后一个初始化脚本只有在全部 SQL 事务和授权成功后,才在 `PGDATA` 内用同文件系统 rename 原子写入 `.rag-bootstrap-complete`;healthcheck 同时检查 `pg_isready` 与该哨兵,防止临时初始化 server 提前放行迁移。Compose 的 `.env` 仅做变量插值,不会自动注入容器;后端从 `*_FILE` 读取 Secret,不把明文密码放进 `.env` 或 `DATABASE_URL`。
|
||||||
|
|
||||||
网络分为四层:`web` 位于普通 `edge` 和 internal `ingress`;`gateway` 位于 `ingress + data`;API、迁移、Worker 和数据库位于 internal `data`;只有明确需要云调用的一次性工具或未来 Worker 才能加入命名的 `egress`。因此浏览器入口不能横向连接数据库,数据库感知 API 也没有公网默认出口。只有无 Secret 的 Web 容器发布回环端口;gateway 固定上游并继续执行请求边界和脱敏错误契约。详细理由和被否决方案见 [ADR-0004](adr/0004-secretless-web-ingress.md)。必须注意:普通 `edge` bridge 为 Web 提供宿主机端口发布时,也给 Web 留有技术上的默认公网出口;当前接受这一点的前提是 Web 无 Secret、无数据网络、无挂载且代理上游固定。若部署基线要求入口容器也完全禁网,必须在宿主机防火墙/出口代理层阻断,不能把“未加入名为 egress 的网络”误写成“没有外网出口”。普通 bridge 本身也不构成域名白名单,正式联网 Worker 仍需主机防火墙或出口代理只放行百炼和对象存储域名。
|
网络分为五层:`web` 位于普通 `edge` 和 internal `ingress`;入口 `gateway` 位于 `ingress + data`;API 位于 internal `data + model`;数据库/迁移位于 internal `data`;只有 `model-gateway` 位于 internal `model + egress`。`provider-smoke` 只持 API 内部 token,真实 `seed-demo` 只持数据库 Secret 和 Worker 内部 token;二者都不挂载百炼 Key、不加入 `egress`。未来 Worker 沿用相同边界。`model-gateway` 不连接数据库、不挂载上传卷、不发布端口。入口隔离见 [ADR-0004](adr/0004-secretless-web-ingress.md),模型出口隔离见 [ADR-0005](adr/0005-isolate-model-egress.md)。必须注意:普通 `edge` bridge 为 Web 提供宿主机端口发布时,也给 Web 留有技术上的默认公网出口;生产仍需主机防火墙或出口代理只允许 `model-gateway` 到百炼域名。
|
||||||
|
|
||||||
### 2.3 后台任务为何不用 Redis
|
### 2.3 后台任务为何不用 Redis
|
||||||
|
|
||||||
@@ -289,7 +329,9 @@ def read_secret(file_env: str, value_env: str) -> str:
|
|||||||
return os.environ[value_env]
|
return os.environ[value_env]
|
||||||
```
|
```
|
||||||
|
|
||||||
生产只允许文件/Secret Manager;本地值环境变量仅作为临时兼容。应用启动后不打印 Settings 的 Secret 字段,异常对象不得包含请求头。
|
生产只允许文件/Secret Manager;本地值环境变量仅作为临时兼容。`bailian_api_key` 只挂载到 `model-gateway`。API 与 Worker 分别挂载 `model_gateway_api_token`、`model_gateway_worker_token`;调用同时使用 Bearer token 和 `X-RAG-Caller`,服务端以常量时间比较并拒绝 token/身份不匹配。应用启动后不打印 Settings 的 Secret 字段,异常对象不得包含请求头。
|
||||||
|
|
||||||
|
Secret 或模型端点配置在运行中发生变化时,`model-gateway` 会原子清空旧 provider 与内部 token,并进入 `restart_required`;旧、新 token 都不能在旧进程继续调用。Docker 的 `restart: unless-stopped` 不会仅因 unhealthy 自动重启,因此轮换操作必须协调执行 `docker compose restart model-gateway api`(Worker 落地后同时重启 Worker),待 readiness 恢复后再运行 provider smoke。禁止只替换文件而让旧进程长期携带旧凭据。
|
||||||
|
|
||||||
### 3.3 Git 防泄漏
|
### 3.3 Git 防泄漏
|
||||||
|
|
||||||
@@ -326,6 +368,10 @@ EMBEDDING_MODEL=text-embedding-v4
|
|||||||
EMBEDDING_DIMENSION=1024
|
EMBEDDING_DIMENSION=1024
|
||||||
RERANK_MODEL=qwen3-rerank
|
RERANK_MODEL=qwen3-rerank
|
||||||
LLM_MODEL=deepseek-v4-flash
|
LLM_MODEL=deepseek-v4-flash
|
||||||
|
|
||||||
|
MODEL_GATEWAY_BASE_URL=http://model-gateway:8000
|
||||||
|
MODEL_GATEWAY_TOKEN_FILE=/run/secrets/model_gateway_api_token
|
||||||
|
MODEL_GATEWAY_CALLER=api
|
||||||
```
|
```
|
||||||
|
|
||||||
启动时 fail fast:
|
启动时 fail fast:
|
||||||
@@ -336,6 +382,8 @@ LLM_MODEL=deepseek-v4-flash
|
|||||||
- Embedding 维度为 schema 支持的 1024;
|
- Embedding 维度为 schema 支持的 1024;
|
||||||
- 生产没有默认密码和通配 CORS;
|
- 生产没有默认密码和通配 CORS;
|
||||||
- Secret 文件存在、非空且权限合理;
|
- Secret 文件存在、非空且权限合理;
|
||||||
|
- 内部模型地址必须精确为 `http://model-gateway:8000`,不能通过配置改成公网或用户控制的 URL;
|
||||||
|
- API/Worker 内部 token 必须不同,caller 必须与 token 身份一致;
|
||||||
- `APP_ENV=production` 时关闭开发文档或增加管理员保护。
|
- `APP_ENV=production` 时关闭开发文档或增加管理员保护。
|
||||||
|
|
||||||
### 4.2 地域与计费方案
|
### 4.2 地域与计费方案
|
||||||
@@ -373,13 +421,15 @@ LLM_MODEL=deepseek-v4-flash
|
|||||||
|
|
||||||
`GET {OPENAI_BASE}/models` 可作为辅助清单,但不能证明 Rerank 路径和三个模型权限都正常,最小实际调用才是最终验证。
|
`GET {OPENAI_BASE}/models` 可作为辅助清单,但不能证明 Rerank 路径和三个模型权限都正常,最小实际调用才是最终验证。
|
||||||
|
|
||||||
|
截至 2026-07-13,最近一次真实探测已到达百炼端点,但 Embedding、Rerank、Chat 三项均返回 401。改由内部 `model-gateway` 路由后必须重新运行同一探测;已有 401 证明不了模型授权可用。必须核对 Key 所属工作空间、北京地域、端点和计费/模型权限后重新验收。401 不进入自动重试风暴,也不能把 Stage 1 标记完成。
|
||||||
|
|
||||||
## 6. 网络与接口安全
|
## 6. 网络与接口安全
|
||||||
|
|
||||||
### 6.1 网络边界
|
### 6.1 网络边界
|
||||||
|
|
||||||
- 只发布 Nginx 端口;数据库、API、Worker 不直接暴露公网;
|
- 只发布 Nginx 端口;数据库、API、Worker 不直接暴露公网;
|
||||||
- Nginx 到 API 使用内部网络;
|
- Nginx 到 API 使用内部网络;
|
||||||
- `data` 网络启用 Docker internal 隔离,Web 不加入;API/Worker 通过独立 `egress` 网络访问云服务;
|
- `data` 和 `model` 网络启用 Docker internal 隔离,Web 不加入;API/Worker 只经 `model-gateway` 访问云服务,自身不加入 `egress`;
|
||||||
- 生产入口启用 TLS、HSTS 和安全响应头;
|
- 生产入口启用 TLS、HSTS 和安全响应头;
|
||||||
- 出站只允许百炼/OSS 等必要域名;
|
- 出站只允许百炼/OSS 等必要域名;
|
||||||
- 数据库账号按迁移、应用、备份职责分权;
|
- 数据库账号按迁移、应用、备份职责分权;
|
||||||
@@ -442,11 +492,12 @@ LLM_MODEL=deepseek-v4-flash
|
|||||||
### 8.1 健康端点
|
### 8.1 健康端点
|
||||||
|
|
||||||
- `/health/live`:进程事件循环可用,不检查外部模型;
|
- `/health/live`:进程事件循环可用,不检查外部模型;
|
||||||
- `/health/ready`:数据库、迁移版本、存储路径和配置可用;
|
- API `/health/ready`:当前验证数据库可用;后续再纳入迁移版本和存储路径对账;
|
||||||
|
- `model-gateway /health/ready`:只验证本地 Key/token/端点配置可加载且未漂移,不产生计费云调用;
|
||||||
- `/admin/providers/health`:按需调用三个模型,结果短期缓存;
|
- `/admin/providers/health`:按需调用三个模型,结果短期缓存;
|
||||||
- `/admin/index/health`:`documents.active_version_id`、searchable 切片投影、非空向量、文本哈希和 embedding profile 一致性。
|
- `/admin/index/health`:`documents.active_version_id`、searchable 切片投影、非空向量、文本哈希和 embedding profile 一致性。
|
||||||
|
|
||||||
百炼短时波动不应让编排器不断重启健康 API 容器。
|
百炼短时波动不应让编排器不断重启健康 API 容器。真实三能力是否可用只能由显式 provider smoke 证明,不能从 `model-gateway` readiness 推断。
|
||||||
|
|
||||||
### 8.2 日志脱敏
|
### 8.2 日志脱敏
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
| 项目 | 当前值 |
|
| 项目 | 当前值 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 基线日期 | 2026-07-12 |
|
| 基线日期 | 2026-07-13 |
|
||||||
| 当前阶段 | Stage 1:模型与数据库 PoC,`IN_PROGRESS` |
|
| 当前阶段 | Stage 1:模型与数据库 PoC,`IN_PROGRESS` |
|
||||||
| 已完成阶段 | Stage 0:仓库、安全和设计基线 |
|
| 已完成阶段 | Stage 0:仓库、安全和设计基线 |
|
||||||
| 整体完成度 | 约 12%,合理区间 10%–15% |
|
| 整体完成度 | 约 12%,合理区间 10%–15% |
|
||||||
| 设计完成度 | 设计基线已完成;实现中发现新约束时继续通过 ADR 和文档修订维护 |
|
| 设计完成度 | 设计基线已完成;实现中发现新约束时继续通过 ADR 和文档修订维护 |
|
||||||
| 业务代码完成度 | 约 18%;Stage 1 的 Docker API、离线检索、数据库 PoC,以及 React 离线演示、Nginx 单入口和四网络隔离前置均已验收;真实百炼 smoke 仍待轮换后的新 Key |
|
| 业务代码完成度 | 不以新增文件提前抬高里程碑;已增加独立 `model-gateway`、内部 token 身份、应用工厂/Problem/trace 和 `0002` profile/cache 迁移,完整产品主链路仍未完成 |
|
||||||
| 当前预计剩余工期 | 约 9–13 周,含 300 题正式标注、盲测、论文和答辩缓冲 |
|
| 当前预计剩余工期 | 约 9–13 周,含 300 题正式标注、盲测、论文和答辩缓冲 |
|
||||||
| 进度权威来源 | 本文的阶段状态、验收证据和已推送提交 |
|
| 进度权威来源 | 本文的阶段状态、验收证据和已推送提交 |
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
| Stage 9 答辩、发布与归档 | 3% | `TODO` | 0% |
|
| Stage 9 答辩、发布与归档 | 3% | `TODO` | 0% |
|
||||||
| **合计** | **100%** | — | **12%** |
|
| **合计** | **100%** | — | **12%** |
|
||||||
|
|
||||||
Stage 1 已完成可离线验收的数据库、适配器契约、synthetic seed、只读 API 和 Web 演示子闭环,安全离线子阶段内部约完成 95%;但真实三模型 smoke 仍依赖轮换后的新 Key,因此 Stage 1 不提前计入整体里程碑。Stage 2 中不依赖云密钥的 React、Nginx、网络隔离和质量门禁前置已经完成,但 Worker 租约、完整产品工作流等必选项尚未完成,Stage 2 仍为 `TODO`。本文复选框表示“已通过验收”,不表示“文件是否已经出现”。当前可诚实表述为:**设计基线完成,整体里程碑约 12%,业务代码约 18%,Stage 1 安全离线链路可运行,真实百炼验证尚未完成。**
|
Stage 1 已完成可离线验收的数据库、适配器契约、synthetic seed、只读 API 和 Web 演示子闭环,并已把百炼 Key/公网出口收敛到独立 `model-gateway`;但真实三模型请求当前均返回 401,因此 Stage 1 不提前计入整体里程碑。Stage 2 中应用工厂、Problem/trace、React、Nginx、网络隔离和质量门禁已作为前置落地,`0002_model_profiles` 迁移代码也已建立;Worker 租约、上传入库、正式检索/聊天和评测等必选项尚未完成,Stage 2 仍为 `TODO`。本文复选框表示“已通过对应层级的验收”,不表示“文件是否已经出现”。当前可诚实表述为:**设计基线完成,整体里程碑仍约 12%,安全离线链路可运行,真实百炼验证和产品主链路尚未完成。**
|
||||||
|
|
||||||
## 2. 阶段依赖与关键路径
|
## 2. 阶段依赖与关键路径
|
||||||
|
|
||||||
@@ -99,8 +99,8 @@ Stage 0 DONE
|
|||||||
|
|
||||||
- `git status --short --branch` 显示 `main...origin/main`,Stage 0 基线提交远端可见。
|
- `git status --short --branch` 显示 `main...origin/main`,Stage 0 基线提交远端可见。
|
||||||
- `make verify-design` 可检查 Secret、设计文档、相对链接和 diff。
|
- `make verify-design` 可检查 Secret、设计文档、相对链接和 diff。
|
||||||
- [00-overall-design.md](00-overall-design.md) 明确状态为“设计基线,待实现验证”。
|
- [00-overall-design.md](00-overall-design.md) 现已同步“设计基线;安全运行骨架部分实现,产品主链路未完成”。
|
||||||
- 当前不存在可运行应用,因此 Stage 0 的完成只代表设计和工程防线完成。
|
- Stage 0 完成时尚无可运行应用;后续运行骨架不改变“Stage 0 只代表设计和工程防线”的历史验收口径。
|
||||||
|
|
||||||
### 已完成提交/推送节点
|
### 已完成提交/推送节点
|
||||||
|
|
||||||
@@ -133,11 +133,13 @@ Stage 0 DONE
|
|||||||
|
|
||||||
- [x] 创建 `backend/pyproject.toml` 和依赖锁文件,不引入未说明依赖。
|
- [x] 创建 `backend/pyproject.toml` 和依赖锁文件,不引入未说明依赖。
|
||||||
- [x] 创建最小 `backend/Dockerfile`,使用非 root 用户和固定基础镜像 digest。
|
- [x] 创建最小 `backend/Dockerfile`,使用非 root 用户和固定基础镜像 digest。
|
||||||
- [x] 创建最小 `compose.yaml`,包含 `db + migrate + provider-smoke + seed-demo/offline`。
|
- [x] 创建 Compose,包含 `db + migrate + model-gateway + api + gateway + web + provider-smoke + seed-demo/offline`;Worker 仍待实现。
|
||||||
- [x] 启动最小 FastAPI `api + gateway`,内部 API 无 egress,gateway 无 Secret 并仅发布回环端口;提供真实数据库 readiness、Swagger 和只读 synthetic demo 检索。
|
- [x] 启动最小 FastAPI `api + gateway`,内部 API 无 egress,gateway 无 Secret 并仅发布回环端口;提供真实数据库 readiness、Swagger 和只读 synthetic demo 检索。
|
||||||
- [x] 创建 PostgreSQL bootstrap、migrator 和 app 分权角色初始化脚本;备份只读角色在备份功能落地时单独创建。
|
- [x] 创建 PostgreSQL bootstrap、migrator 和 app 分权角色初始化脚本;备份只读角色在备份功能落地时单独创建。
|
||||||
- [x] 创建 Alembic 基线迁移,启用 pgvector 并建立 1024 维向量表/HNSW 基线。
|
- [x] 创建 Alembic 基线迁移,启用 pgvector 并建立 1024 维向量表/HNSW 基线。
|
||||||
- [x] 确保运行期服务不挂载 bootstrap 或 migrator Secret。
|
- [x] 确保运行期服务不挂载 bootstrap 或 migrator Secret。
|
||||||
|
- [x] 将百炼 Key 和公网出口隔离到独立 `model-gateway`;API/seed/smoke 只持对应内部 token,不直持 Key。
|
||||||
|
- [x] 以 `Authorization: Bearer` + `X-RAG-Caller: api|worker` 建立内部身份,常量时间比较并限制 API 身份不得执行文档向量化。
|
||||||
|
|
||||||
#### 4.3 三模型适配器
|
#### 4.3 三模型适配器
|
||||||
|
|
||||||
@@ -157,6 +159,7 @@ Stage 0 DONE
|
|||||||
- [x] 建立并执行迁移空 volume、角色权限和 Docker 重启持久化测试。
|
- [x] 建立并执行迁移空 volume、角色权限和 Docker 重启持久化测试。
|
||||||
- [x] 明确文档族 split、盲测保管人和“盲测用于调参即退役”规则。
|
- [x] 明确文档族 split、盲测保管人和“盲测用于调参即退役”规则。
|
||||||
- [x] 扩展 `make verify`,纳入 Stage 1 后端格式、类型和测试门禁。
|
- [x] 扩展 `make verify`,纳入 Stage 1 后端格式、类型和测试门禁。
|
||||||
|
- [x] 实现并实跑 `0002_model_profiles`:profile、embedding cache、chunk assignment、model invocation 与稳定 `citation_id`;空卷和已有数据均完成 upgrade/downgrade/upgrade。
|
||||||
|
|
||||||
### 验收证据
|
### 验收证据
|
||||||
|
|
||||||
@@ -165,12 +168,13 @@ Stage 0 DONE
|
|||||||
- [x] `docker compose run --rm seed-demo-offline` 完成 20 条虚构数据写入、检索和重排;真实模式待新 Key。
|
- [x] `docker compose run --rm seed-demo-offline` 完成 20 条虚构数据写入、检索和重排;真实模式待新 Key。
|
||||||
- [x] `api + gateway` 镜像构建并达到 healthy;live/ready/meta/demo status/demo search 均通过真实 HTTP 验收。
|
- [x] `api + gateway` 镜像构建并达到 healthy;live/ready/meta/demo status/demo search 均通过真实 HTTP 验收。
|
||||||
- [x] 第二次运行 seed 后数据库计数与第一次相同,均为 chunks/vectors/searchable = 20/20/20。
|
- [x] 第二次运行 seed 后数据库计数与第一次相同,均为 chunks/vectors/searchable = 20/20/20。
|
||||||
- [x] 65 项后端测试证明模型契约、健康检查、固定上游 gateway、SSE 代理分块与早断连释放契约、离线 demo、数据治理和失败路径正确。
|
- [x] 已验证测试集证明模型契约、健康检查、固定上游 gateway、SSE 代理分块与早断连释放契约、离线 demo、数据治理和失败路径正确;本轮新增 model-gateway/0002 契约须随阶段提交重新运行全量门禁。
|
||||||
- [x] `make verify`、Secret 扫描、固定镜像构建和 `git diff --check` 通过。
|
- [x] `make verify`、Secret 扫描、固定镜像构建和 `git diff --check` 通过。
|
||||||
|
|
||||||
### 2026-07-12 已验证运行证据
|
### 2026-07-12 已验证运行证据
|
||||||
|
|
||||||
- 全新 volume 上 `db` 达到 healthy,`migrate` 以非超级用户退出码 0,版本为 `0001_initial_schema`。
|
- 全新 volume 上 `db` 达到 healthy,`migrate` 以非超级用户退出码 0,版本为 `0001_initial_schema`。
|
||||||
|
- `0002_model_profiles` 已在独立全新 volume 完成 `空库 -> 0001 -> 0002 -> 0001 -> 0002`,并在已有 20 条合成数据的 volume 完成 downgrade/upgrade;重复 seed 后 profile/active KB=1/1、cache/READY assignment/citation=20/20/20,citation 无重复。
|
||||||
- `vector` 扩展与 HNSW 基线存在;app/migrator 均非超级用户,app 无 `rag` schema DDL 权限但具有所需 DML。
|
- `vector` 扩展与 HNSW 基线存在;app/migrator 均非超级用户,app 无 `rag` schema DDL 权限但具有所需 DML。
|
||||||
- 两次离线 seed 均输出 20/20/20,9 个可回答虚构问题 Hit@3 = 9/9。
|
- 两次离线 seed 均输出 20/20/20,9 个可回答虚构问题 Hit@3 = 9/9。
|
||||||
- FastAPI 容器使用 app 最小权限角色,根文件系统只读且无 egress;无 Secret gateway 仅提供回环入口;Swagger 可见,demo search 返回合成片段与不透明 citation ID。
|
- FastAPI 容器使用 app 最小权限角色,根文件系统只读且无 egress;无 Secret gateway 仅提供回环入口;Swagger 可见,demo search 返回合成片段与不透明 citation ID。
|
||||||
@@ -179,9 +183,9 @@ Stage 0 DONE
|
|||||||
- app 尝试修改已 `CLOUD_APPROVED` 的 `cloud_text` 被审批不可变触发器拒绝;尝试建表被权限拒绝。
|
- app 尝试修改已 `CLOUD_APPROVED` 的 `cloud_text` 被审批不可变触发器拒绝;尝试建表被权限拒绝。
|
||||||
- React + TypeScript strict 离线演示已实现工作台与系统状态页;14 项前端测试、ESLint、类型检查和生产构建通过。
|
- React + TypeScript strict 离线演示已实现工作台与系统状态页;14 项前端测试、ESLint、类型检查和生产构建通过。
|
||||||
- Nginx 成为唯一回环入口,Compose 仅发布 `127.0.0.1:8000`;`web -> gateway -> api` 同源链路、Swagger、OpenAPI、健康检查和 synthetic search 均通过真实 HTTP 验收。
|
- Nginx 成为唯一回环入口,Compose 仅发布 `127.0.0.1:8000`;`web -> gateway -> api` 同源链路、Swagger、OpenAPI、健康检查和 synthetic search 均通过真实 HTTP 验收。
|
||||||
- `edge / ingress / data / egress` 四网络边界已落地;Web 不可解析数据库,API 与 gateway 无公网 TCP 出口,Web 与 gateway 均不持有 Secret。
|
- `edge / ingress / data / model / egress` 五网络边界已落地;API、seed、smoke 不挂载百炼 Key,只有不连数据库/上传卷的 `model-gateway` 同时连接 internal model 与 egress。
|
||||||
- gateway 与 Nginx 已关闭代理缓冲,自动测试证明两个 SSE 事件保持为独立下游分块,且响应头阶段断连也会释放上游流;真实聊天生成与取消传播仍属于 Stage 5,尚未实现。
|
- gateway 与 Nginx 已关闭代理缓冲,自动测试证明两个 SSE 事件保持为独立下游分块,且响应头阶段断连也会释放上游流;真实聊天生成与取消传播仍属于 Stage 5,尚未实现。
|
||||||
- 唯一未验收项是真实 `text-embedding-v4` / `qwen3-rerank` / `deepseek-v4-flash` smoke 与真实模式 seed。
|
- 当前真实 `text-embedding-v4` / `qwen3-rerank` / `deepseek-v4-flash` 请求均到达供应商但返回 401;三能力与真实模式 seed 未验收。上传、正式检索、grounded chat、Worker 和评测仍属于后续阶段,不能称为“唯一剩余项”。
|
||||||
|
|
||||||
### 提交/推送节点
|
### 提交/推送节点
|
||||||
|
|
||||||
@@ -206,16 +210,16 @@ Stage 0 DONE
|
|||||||
- **状态:** `TODO`
|
- **状态:** `TODO`
|
||||||
- **预计工期:** 3–4 个工作日
|
- **预计工期:** 3–4 个工作日
|
||||||
- **依赖:** Stage 1 `DONE`
|
- **依赖:** Stage 1 `DONE`
|
||||||
- **已完成前置:** 不依赖云密钥的 React 离线演示、OpenAPI 类型、Nginx 单入口、四网络隔离和当前质量门禁已验收;这不代表 Stage 2 已启动或完成。
|
- **已完成前置:** 不依赖云密钥的 React 离线演示、OpenAPI 类型、Nginx 单入口和质量门禁已验收;模型隔离网络加入后当前为五层网络。这不代表 Stage 2 已启动或完成。
|
||||||
|
|
||||||
### 具体实现任务
|
### 具体实现任务
|
||||||
|
|
||||||
- [ ] 建立 FastAPI 应用工厂、配置加载、稳定错误码和 OpenAPI。
|
- [x] 建立 FastAPI 应用工厂、配置加载、稳定 Problem JSON、trace middleware 和 OpenAPI;导入阶段不连库、不读模型凭证。
|
||||||
- [ ] 按 `domain -> ports -> services -> adapters/persistence` 落实后端依赖边界。
|
- [ ] 按 `domain -> ports -> services -> adapters/persistence` 落实后端依赖边界。
|
||||||
- [ ] 建立 SQLAlchemy 仓储、Alembic 迁移运行器和事务边界。
|
- [ ] 建立完整 SQLAlchemy 仓储和业务事务边界;Alembic 已推进到 `0002_model_profiles`,且空卷/已有数据升降级已验证。
|
||||||
- [ ] 建立 PostgreSQL `background_jobs`、租约、lease token、fencing 和重试状态机。
|
- [ ] 建立 PostgreSQL `background_jobs`、租约、lease token、fencing 和重试状态机。
|
||||||
- [ ] 建立 Worker maintenance loop、advisory-lock reaper 和强杀恢复测试。
|
- [ ] 建立 Worker maintenance loop、advisory-lock reaper 和强杀恢复测试。
|
||||||
- [ ] 建立 `/health/live`、`/health/ready` 和管理员模型能力探测端点。
|
- [ ] `/health/live`、数据库 `/health/ready` 及 model-gateway 本地 readiness 已有;管理员模型能力探测 API 仍待实现。
|
||||||
- [x] 建立 React + TypeScript strict、路由、TanStack Query 和 OpenAPI 客户端生成。
|
- [x] 建立 React + TypeScript strict、路由、TanStack Query 和 OpenAPI 客户端生成。
|
||||||
- [x] 建立 Nginx,仅发布 Web 端口;当前 API、DB 只在内部网络,Worker 落地后必须沿用同一边界。
|
- [x] 建立 Nginx,仅发布 Web 端口;当前 API、DB 只在内部网络,Worker 落地后必须沿用同一边界。
|
||||||
- [ ] 建立完整 Compose:`web/api/worker/db/migrate`,OCR profile 默认关闭。
|
- [ ] 建立完整 Compose:`web/api/worker/db/migrate`,OCR profile 默认关闭。
|
||||||
@@ -229,11 +233,11 @@ Stage 0 DONE
|
|||||||
- [x] 当前 `docker compose ps` 只暴露 Nginx/Web 回环端口;加入 Worker 后须重新验收。
|
- [x] 当前 `docker compose ps` 只暴露 Nginx/Web 回环端口;加入 Worker 后须重新验收。
|
||||||
- [ ] Docker 重启后数据库数据保留。
|
- [ ] Docker 重启后数据库数据保留。
|
||||||
- [ ] 强杀 Worker 后验证可重领、耗尽进入 `FAILED`、旧 token 不能提交。
|
- [ ] 强杀 Worker 后验证可重领、耗尽进入 `FAILED`、旧 token 不能提交。
|
||||||
- [x] 当前后端 65 项、前端 14 项测试及两端 lint、类型和构建门禁均通过;Stage 2 新功能仍需增量验证。
|
- [x] 已提交基线的两端 lint、类型、测试和构建门禁通过;本轮新增 model-gateway、应用工厂和 `0002` 仍须在提交前以 `make verify` 重新确认。
|
||||||
|
|
||||||
### 提交/推送节点
|
### 提交/推送节点
|
||||||
|
|
||||||
- [x] `S2-PREP`:React 离线演示、Nginx 单入口、四网络隔离、SSE 代理安全修复与质量门禁已随 `c3bad0f` 验证并推送;该前置提交不代表 Stage 2 已启动或完成。
|
- [x] `S2-PREP`:React 离线演示、Nginx 单入口、当时的四网络隔离、SSE 代理安全修复与质量门禁已随 `c3bad0f` 验证并推送;后续 `model` 网络把当前拓扑扩为五层,该前置提交不代表 Stage 2 已启动或完成。
|
||||||
- [ ] `S2-A`:FastAPI、持久化、健康端点和错误契约。
|
- [ ] `S2-A`:FastAPI、持久化、健康端点和错误契约。
|
||||||
- [ ] `S2-B`:任务租约、reaper、fencing 和恢复测试。
|
- [ ] `S2-B`:任务租约、reaper、fencing 和恢复测试。
|
||||||
- [ ] `S2-C`:React、Nginx、完整 Compose、CI 和文档;其中 React/Nginx/CI 前置已完成,缺 Worker 与完整 Compose 验收,节点不得提前关闭。
|
- [ ] `S2-C`:React、Nginx、完整 Compose、CI 和文档;其中 React/Nginx/CI 前置已完成,缺 Worker 与完整 Compose 验收,节点不得提前关闭。
|
||||||
@@ -561,12 +565,12 @@ Stage 0 DONE
|
|||||||
|
|
||||||
## 15. 下一步执行顺序
|
## 15. 下一步执行顺序
|
||||||
|
|
||||||
当前主线仍是关闭 Stage 1 的真实供应商门禁;已完成的 React/Nginx 工作仅作为 Stage 2 安全前置保留。建议按以下顺序继续:
|
当前主线仍是关闭 Stage 1 的真实供应商门禁,同时继续不依赖云授权的安全骨架。建议按以下顺序继续:
|
||||||
|
|
||||||
1. 在百炼控制台撤销聊天中暴露的旧 Key,创建新 Key,并仅通过未提交的 Docker Secret 注入。
|
1. 在百炼控制台确认旧 Key 已撤销,并核对当前本地 Secret 对应的工作空间、北京地域、端点、计费方案和模型权限;Key 只保存在未提交 Secret 中。
|
||||||
2. 分别运行 `text-embedding-v4`、`qwen3-rerank`、`deepseek-v4-flash` 受控 live smoke,记录脱敏 provider profile、能力指纹、维度、流式协议和错误映射。
|
2. 通过独立 `model-gateway` 重跑三能力 smoke;当前三项 401 未消除前保持 S1-B 未完成。
|
||||||
3. 用现有 20 条 synthetic 数据运行真实 Embedding/Rerank/Chat 链路和幂等 seed;真实地质资料仍不得出域。
|
3. 三能力成功后,用现有 20 条 synthetic 数据运行真实 Embedding/Rerank/Chat 链路和幂等 seed;真实地质资料仍不得出域。
|
||||||
4. 关闭 S1-B 并把 Stage 1 标记为 `DONE` 后,再实现 Stage 2 的 Worker 租约、fencing、reaper、恢复测试和完整 Compose。
|
4. 继续实现 Stage 2 的 Worker 租约、fencing、reaper、恢复测试和完整 Compose,不让外部 401 阻塞可离线验证的工程工作。
|
||||||
5. Stage 3 实现数字 PDF/DOCX/TXT/Markdown 的隔离解析、页码/章节恢复、审批状态机、切分、向量化写库和可追溯版本激活。
|
5. Stage 3 实现数字 PDF/DOCX/TXT/Markdown 的隔离解析、页码/章节恢复、审批状态机、切分、向量化写库和可追溯版本激活。
|
||||||
6. Stage 4 完成真实向量召回、重排与验证集评测;Stage 5 再实现 grounded chat SSE、取消传播、引用解析和聊天 UI,不把当前代理分块测试误称为聊天功能完成。
|
6. Stage 4 完成真实向量召回、重排与验证集评测;Stage 5 再实现 grounded chat SSE、取消传播、引用解析和聊天 UI,不把当前代理分块测试误称为聊天功能完成。
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ make backend-sync
|
|||||||
bash scripts/init-local-secrets.sh
|
bash scripts/init-local-secrets.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本只生成三组彼此不同的随机 PostgreSQL 密码,文件权限为 `0600`。不带 `--with-bailian` 时不会创建模型 Key。
|
脚本生成三组彼此不同的 PostgreSQL 密码,以及互不相同的 `model_gateway_api_token`、`model_gateway_worker_token`,文件权限为 `0600`。不带 `--with-bailian` 时不会创建或修改百炼 Key;若本地已存在 Key,脚本会明确提示保留原文件。
|
||||||
|
|
||||||
## 2. 离线数据库与向量 PoC
|
## 2. 离线数据库与向量 PoC
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ Seed 的实际流程是:
|
|||||||
|
|
||||||
### 2.1 查看后端运行效果
|
### 2.1 查看后端运行效果
|
||||||
|
|
||||||
内部 API 只连接 `internal` data 网络,只挂载 app 数据库 Secret,不连接外网,也不挂载百炼 Key。无 Secret、无数据库凭证的 gateway 连接 internal ingress/data;React/Nginx Web 连接 edge/ingress,并且是唯一发布到本机回环地址 `127.0.0.1:8000` 的容器。三个运行容器均使用非 root 用户、只读根文件系统并移除 Linux capabilities。
|
内部 API 只连接 internal `data + model`,挂载 app 数据库 Secret 和 API 内部 token,不连接公网,也不挂载百炼 Key。独立 `model-gateway` 只连接 internal `model + egress`,是唯一挂载百炼 Key 的服务;它不连接数据库、不挂载上传卷、不发布端口。真实 `provider-smoke` 和 `seed-demo` 也只通过内部 token 调用该服务,不直持 Key。无数据库/模型 Secret 的入口 gateway 连接 internal ingress/data;React/Nginx Web 连接 edge/ingress,并且是唯一发布到本机回环地址 `127.0.0.1:8000` 的容器。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:8000/health/live
|
curl http://127.0.0.1:8000/health/live
|
||||||
@@ -82,7 +82,7 @@ bash scripts/init-local-secrets.sh --with-bailian
|
|||||||
docker compose --profile tools run --rm provider-smoke
|
docker compose --profile tools run --rm provider-smoke
|
||||||
```
|
```
|
||||||
|
|
||||||
`provider-smoke` 依次验证:
|
`provider-smoke` 以 API 内部身份调用 `model-gateway`,再由后者依次验证:
|
||||||
|
|
||||||
- `text-embedding-v4` 返回 1 个 1024 维有限非零向量;
|
- `text-embedding-v4` 返回 1 个 1024 维有限非零向量;
|
||||||
- `qwen3-rerank` 返回可映射到本地候选的下标和分数;
|
- `qwen3-rerank` 返回可映射到本地候选的下标和分数;
|
||||||
@@ -97,7 +97,9 @@ docker compose --profile tools run --rm seed-demo
|
|||||||
docker compose --profile tools run --rm seed-demo
|
docker compose --profile tools run --rm seed-demo
|
||||||
```
|
```
|
||||||
|
|
||||||
真实模式仍只允许本仓库的虚构样例;任何真实地质报告必须等 Stage 3 的许可、涉密和 outbound manifest 审核链完成。
|
真实模式使用 Worker 内部身份,仍只允许本仓库的虚构样例;任何真实地质报告必须等 Stage 3 的许可、涉密和 outbound manifest 审核链完成。
|
||||||
|
|
||||||
|
截至 2026-07-13,已通过独立 `model-gateway` 重跑三项真实调用:三项都到达供应商,但均返回安全脱敏的 `authentication` 类别(内部非流式调用由 Gateway 以 502 封装,Chat SSE 以终态错误事件返回;根因仍是供应商鉴权失败)。此时应核对 Key 所属工作空间、北京地域、专属端点、计费方案和模型权限,不要反复自动重试。在三项成功前不得声明百炼接入验收通过。
|
||||||
|
|
||||||
## 4. 质量门禁与排障
|
## 4. 质量门禁与排障
|
||||||
|
|
||||||
@@ -113,6 +115,7 @@ docker compose logs --no-log-prefix migrate
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `invalid_local_configuration` | 检查是否仍是占位 URL、两个 URL 是否同一北京工作空间、新 Key 文件是否存在 |
|
| `invalid_local_configuration` | 检查是否仍是占位 URL、两个 URL 是否同一北京工作空间、新 Key 文件是否存在 |
|
||||||
| migrate 等不到 DB | 检查三个数据库 Secret 是否存在且互不相同;查看 bootstrap 日志 |
|
| migrate 等不到 DB | 检查三个数据库 Secret 是否存在且互不相同;查看 bootstrap 日志 |
|
||||||
|
| `migrate` 显示 `Exited (0)` | 正常:它是一次性 Alembic 服务,成功升级到 head 后就应退出;只有非 0 或反复重启才是故障 |
|
||||||
| 401/403 | 不重试风暴;检查新 Key、工作空间和模型授权 |
|
| 401/403 | 不重试风暴;检查新 Key、工作空间和模型授权 |
|
||||||
| 429/5xx/timeout | 适配器执行有界指数退避;持续失败时保留脱敏 request ID 后停止 |
|
| 429/5xx/timeout | 适配器执行有界指数退避;持续失败时保留脱敏 request ID 后停止 |
|
||||||
| seed 计数不是 20 | 不进入下一阶段;检查迁移、manifest 约束和事务日志 |
|
| seed 计数不是 20 | 不进入下一阶段;检查迁移、manifest 约束和事务日志 |
|
||||||
@@ -127,4 +130,4 @@ docker compose down
|
|||||||
|
|
||||||
## 5. 当前完成边界
|
## 5. 当前完成边界
|
||||||
|
|
||||||
可在没有百炼 Key 时验收:配置/Secret 安全、MockTransport 契约、离线向量/重排、Compose 渲染、空卷迁移、20 条写入和幂等。只有轮换后的新 Key 完成三模型真实 smoke 与真实 seed 后,Stage 1 才能从 `IN_PROGRESS` 改为 `DONE`。
|
可在没有可用百炼权限时验收:配置/Secret 安全、内部 token 身份、MockTransport 契约、离线向量/重排、Compose 渲染、迁移、20 条写入和幂等。当前代码还包含 FastAPI 应用工厂、稳定 Problem/trace 契约,以及 `0002_model_profiles` 的 profile/cache/assignment/invocation/citation 迁移;这些不代表上传、正式检索、grounded chat、Worker 和评测已完成。只有有效 Key 完成三模型真实 smoke 与真实 seed 后,Stage 1 才能从 `IN_PROGRESS` 改为 `DONE`。
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
|
|
||||||
- Stage 0“仓库、安全和设计基线”已完成并推送。
|
- Stage 0“仓库、安全和设计基线”已完成并推送。
|
||||||
- Stage 1“模型与数据库 PoC”正在进行。
|
- Stage 1“模型与数据库 PoC”正在进行。
|
||||||
- 当前整体完成度约 12%,合理区间为 10%–15%。
|
- 当前整体里程碑仍约 12%,合理区间为 10%–15%;新增代码不在阶段验收前提前计入完成度。
|
||||||
- 00–03 的设计基线已完成;业务代码完成度约 18%,Stage 1 安全离线子阶段约 95%。
|
- 00–03 的设计基线已完成;后端离线链路、20 条 synthetic 数据、React 离线演示和 Nginx 单入口已验收。
|
||||||
- 后端离线链路、20 条 synthetic 数据、React 离线演示、Nginx 单入口和四网络隔离均已验收;真实百炼三模型 smoke 仍待旧 Key 撤销并轮换。
|
- 五层网络与独立 `model-gateway` 已实现:只有该服务持百炼 Key/egress,API、seed、smoke 使用区分 API/Worker 身份的内部 token。
|
||||||
- Stage 2 仍为 `TODO`:当前只完成了不依赖云密钥的前置,Worker 租约、完整 Compose 和产品工作流尚未完成。
|
- FastAPI 应用工厂、稳定 Problem/trace 契约,以及 `0002_model_profiles` 的 profile/cache/assignment/invocation/citation 迁移代码已落地。
|
||||||
|
- 真实 `text-embedding-v4`、`qwen3-rerank`、`deepseek-v4-flash` 当前仍全部返回 401,真实百炼能力尚未验收。
|
||||||
|
- Stage 2 仍为 `TODO`:Worker 租约/fencing/reaper、上传入库、正式检索、grounded chat、评测和完整产品 UI 尚未完成。
|
||||||
- 后续状态、依赖、验收证据、工期和提交节点以 [项目全生命周期 TODO](04-project-todo.md) 为准。
|
- 后续状态、依赖、验收证据、工期和提交节点以 [项目全生命周期 TODO](04-project-todo.md) 为准。
|
||||||
|
|
||||||
## 文档列表
|
## 文档列表
|
||||||
|
|||||||
68
docs/adr/0005-isolate-model-egress.md
Normal file
68
docs/adr/0005-isolate-model-egress.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR-0005:使用独立 Model Gateway 隔离百炼出口
|
||||||
|
|
||||||
|
- **状态:** accepted
|
||||||
|
- **日期:** 2026-07-13
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
在线 RAG 必须同步完成 Query Embedding、Rerank 和 Chat SSE,但 ADR-0004 又要求数据库感知 API 不持有百炼 Key、没有公网默认出口。让 API 或 Worker 直接调用百炼,会把业务数据、数据库凭据、模型凭据和公网出口集中到同一信任边界;只保留一次性 smoke/seed 工具则无法支持在线问答。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
新增与 API 共用 backend 镜像的长期 `model-gateway` 服务:
|
||||||
|
|
||||||
|
```text
|
||||||
|
browser
|
||||||
|
-> web (edge + ingress)
|
||||||
|
-> gateway (ingress + data)
|
||||||
|
-> api (data + model)
|
||||||
|
-> PostgreSQL/pgvector (data)
|
||||||
|
-> model-gateway (model + egress)
|
||||||
|
-> Alibaba Cloud Model Studio
|
||||||
|
|
||||||
|
worker (data + model)
|
||||||
|
-> PostgreSQL/pgvector
|
||||||
|
-> model-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只有 `model-gateway` 挂载 `bailian_api_key` 并连接 `egress`;它不连接 `data`、不挂数据库 Secret、上传卷或宿主端口。
|
||||||
|
- API 与 Worker 不挂百炼 Key、不连接 `egress`,只通过 internal `model` 网络访问固定的 `http://model-gateway:8000`。
|
||||||
|
- API 与 Worker 使用不同的内部令牌。调用必须同时携带 `Authorization: Bearer ...` 与 `X-RAG-Caller`;Gateway 用常量时间比较把令牌映射到调用者,不能只信任 header 或 Docker 来源地址。
|
||||||
|
- API 允许 Query Embedding、Rerank、Chat 和受控 provider health;Worker 额外允许 Document Embedding。任意越权调用失败关闭。
|
||||||
|
- Model Gateway 只接受固定 profile、固定模型和固定端点的 vendor-neutral DTO,不接受客户端提供 URL、模型名、维度或任意转发目标。
|
||||||
|
- Embedding、Rerank 和 Chat 继续复用现有 provider ports 与百炼 adapter,不引入 SDK、Redis、消息队列、服务网格或第二套实现。
|
||||||
|
- 日志只允许 trace、调用者、操作、profile、输入数量、usage、耗时、request ID 和脱敏错误码;禁止记录 Key、Authorization、URL、query、messages、候选正文或响应正文。
|
||||||
|
- 容器 readiness 只验证本地配置、Secret 和客户端初始化,不产生模型费用;真实探测使用受控内部端点和显式命令。
|
||||||
|
|
||||||
|
## 内部接口
|
||||||
|
|
||||||
|
- `POST /internal/v1/embeddings`:接收 `texts` 和 `input_type=query|document`;Query 恰好一条,Document 为 1–10 条;返回 1024 维向量、resolved model、usage、request ID 和耗时。
|
||||||
|
- `POST /internal/v1/rerank`:接收 query、候选正文和 Top N;返回原始下标、score、受校验正文与 provider 元数据。
|
||||||
|
- `POST /internal/v1/chat/completions`:非流式内部调用,用于需要先完成引用校验的场景。
|
||||||
|
- `POST /internal/v1/chat/stream`:vendor-neutral SSE;流开始后的 provider 错误转换为终态 `error` 事件,取消必须关闭上游流。
|
||||||
|
- `GET /health/live` 与 `GET /health/ready`:不调用百炼。
|
||||||
|
|
||||||
|
公共 API 不把 Model Gateway 的内部 401/403 原样返回浏览器;这类错误统一映射为可观测的 503 配置故障。429、timeout、5xx 和非法响应分别映射为稳定、脱敏的错误码。首个输出之后不得自动重试 Chat,避免重复文本。
|
||||||
|
|
||||||
|
## 被否决方案
|
||||||
|
|
||||||
|
1. **API 直接挂 Key 与 egress:** 数据库感知进程同时拥有数据、模型凭据和公网出口。
|
||||||
|
2. **Worker 直接调用百炼:** 会形成两个供应商调用实现和两个 Key 边界。
|
||||||
|
3. **Model Gateway 连接数据库验证审批:** 重新形成“数据 + Key + egress”的高风险组合。
|
||||||
|
4. **只依赖 Docker DNS 或来源 IP:** 网络成员不是应用层身份,不能替代内部令牌。
|
||||||
|
5. **引入通用 API Gateway、Redis、Celery 或服务网格:** 当前单机毕设规模没有足够收益,并增加部署与恢复面。
|
||||||
|
|
||||||
|
## 后续约束
|
||||||
|
|
||||||
|
- `provider-smoke` 与真实 seed 必须使用 Model Gateway 客户端,不允许恢复直连百炼旁路。
|
||||||
|
- 普通在线查询只能使用知识库已激活的 embedding profile;Fake 与 Bailian profile 永不混查。
|
||||||
|
- Docker bridge 不是域名白名单。生产环境仍需主机防火墙或出口代理只放行获批的百炼域名。
|
||||||
|
- 改变模型调用者、内部鉴权、网络成员、Key 边界或 SSE 协议时,必须更新本 ADR 并重跑容器网络、Secret、取消和错误脱敏验收。
|
||||||
|
|
||||||
|
## 验证要求
|
||||||
|
|
||||||
|
- Compose 合同证明百炼 Key 与 `egress` 只存在于 `model-gateway`。
|
||||||
|
- Model Gateway 无数据库 Secret、`data` 网络、上传卷和宿主端口;API/Worker 无百炼 Key和 `egress`。
|
||||||
|
- 缺失/错误令牌、调用者冒充、scope 越权、URL/模型/维度注入均有失败测试。
|
||||||
|
- Embedding、Rerank、Chat 的成功、401、429、timeout、5xx、非法响应、SSE 取消均由 hermetic contract test 覆盖。
|
||||||
|
- `make verify`、镜像 Secret 扫描、Docker 健康检查、网络隔离与真实三模型 smoke 全部通过后,才能宣称在线百炼模型边界可用。
|
||||||
@@ -8,3 +8,4 @@ ADR 用于记录会长期影响系统的技术决策。状态使用 `proposed`
|
|||||||
- [0002-separate-bailian-protocols.md](0002-separate-bailian-protocols.md):分离百炼 Chat/Embedding 与 Rerank 协议适配器。
|
- [0002-separate-bailian-protocols.md](0002-separate-bailian-protocols.md):分离百炼 Chat/Embedding 与 Rerank 协议适配器。
|
||||||
- [0003-text-first-scope.md](0003-text-first-scope.md):第一版采用文本优先边界,不宣称地质图空间理解。
|
- [0003-text-first-scope.md](0003-text-first-scope.md):第一版采用文本优先边界,不宣称地质图空间理解。
|
||||||
- [0004-secretless-web-ingress.md](0004-secretless-web-ingress.md):用无 Secret 的 Nginx Web 与固定上游 gateway 隔离浏览器、API 和数据库网络。
|
- [0004-secretless-web-ingress.md](0004-secretless-web-ingress.md):用无 Secret 的 Nginx Web 与固定上游 gateway 隔离浏览器、API 和数据库网络。
|
||||||
|
- [0005-isolate-model-egress.md](0005-isolate-model-egress.md):用独立 Model Gateway 隔离百炼 Key、模型出口与数据库感知服务。
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ generate_secret() {
|
|||||||
generate_secret postgres_bootstrap_password
|
generate_secret postgres_bootstrap_password
|
||||||
generate_secret postgres_migrator_password
|
generate_secret postgres_migrator_password
|
||||||
generate_secret postgres_app_password
|
generate_secret postgres_app_password
|
||||||
|
generate_secret model_gateway_api_token
|
||||||
|
generate_secret model_gateway_worker_token
|
||||||
|
|
||||||
if [[ "${1:-}" == "--with-bailian" ]]; then
|
if [[ "${1:-}" == "--with-bailian" ]]; then
|
||||||
bailian_path="$secret_dir/bailian_api_key"
|
bailian_path="$secret_dir/bailian_api_key"
|
||||||
@@ -41,5 +43,9 @@ if [[ "${1:-}" == "--with-bailian" ]]; then
|
|||||||
unset bailian_key
|
unset bailian_key
|
||||||
printf 'Created local Bailian secret without echoing its value: %s\n' "$bailian_path"
|
printf 'Created local Bailian secret without echoing its value: %s\n' "$bailian_path"
|
||||||
else
|
else
|
||||||
printf 'Bailian key not created. Rotate the exposed key, then rerun with --with-bailian.\n'
|
if [[ -s "$secret_dir/bailian_api_key" ]]; then
|
||||||
|
printf 'Existing local Bailian secret was not modified.\n'
|
||||||
|
else
|
||||||
|
printf 'Bailian key not created. Rotate the exposed key, then rerun with --with-bailian.\n'
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user