Isolate cloud model access before enabling product RAG workflows
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:
2026-07-13 04:09:06 +08:00
parent 99b7df64ea
commit 75592af33a
28 changed files with 3932 additions and 254 deletions

View File

@@ -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

View 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,
)

View File

@@ -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:

View 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,
),
)

View 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

View File

@@ -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

View 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()

View File

@@ -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,17 +26,9 @@ 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,
model=settings.embedding_model,
dimensions=settings.embedding_dimension,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
)
try:
result = await adapter.embed_documents(["用于能力探测的虚构地质文本。"])
if len(result.vectors) != 1 or len(result.vectors[0]) != settings.embedding_dimension: if len(result.vectors) != 1 or len(result.vectors[0]) != settings.embedding_dimension:
raise RuntimeError("embedding contract mismatch") raise RuntimeError("embedding contract mismatch")
return ProbeResult( return ProbeResult(
@@ -50,19 +38,9 @@ async def probe_embedding(settings: Settings, api_key: str) -> ProbeResult:
elapsed_ms=round(result.elapsed_ms, 2), elapsed_ms=round(result.elapsed_ms, 2),
request_id=result.request_id, 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(
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,
)
try:
result = await adapter.rerank( result = await adapter.rerank(
"哪段文本提到了斑岩铜矿?", "哪段文本提到了斑岩铜矿?",
["虚构斑岩铜矿具有钾化带。", "虚构煤层采用测井曲线对比。"], ["虚构斑岩铜矿具有钾化带。", "虚构煤层采用测井曲线对比。"],
@@ -77,23 +55,13 @@ async def probe_rerank(settings: Settings, api_key: str) -> ProbeResult:
elapsed_ms=round(result.elapsed_ms, 2), elapsed_ms=round(result.elapsed_ms, 2),
request_id=result.request_id, request_id=result.request_id,
) )
finally:
await adapter.aclose()
async def probe_chat(settings: Settings, api_key: str) -> ProbeResult: async def probe_chat(_: Settings, adapter: ModelGatewayAdapter) -> 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,
)
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,
@@ -111,8 +79,6 @@ async def probe_chat(settings: Settings, api_key: str) -> ProbeResult:
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),
) )
try:
results = [] results = []
for capability, operation in probes: for capability, operation in probes:
result = await run_probe(capability, operation, settings, api_key) result = await run_probe(capability, operation, settings, adapter)
results.append(result) results.append(result)
write_json_line(asdict(result)) write_json_line(asdict(result))
return 0 if all(result.status == "ok" for result in results) else 1 return 0 if all(result.status == "ok" for result in results) else 1
finally:
await adapter.aclose()
def main() -> None: def main() -> None:

View File

@@ -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:

View File

@@ -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;")

View File

@@ -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

View File

@@ -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)

View 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"] == []

View File

@@ -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")

View 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"

View 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")

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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/1cache/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)

View File

@@ -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 工作空间的向量不会错误共用缓存。

View File

@@ -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 登录角色和专属 schemamigrator 拥有 schema/DDLapp 仅获运行期所需的 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 登录角色和专属 schemamigrator 拥有 schema/DDLapp 仅获运行期所需的 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 日志脱敏

View File

@@ -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 迁移,完整产品主链路仍未完成 |
| 当前预计剩余工期 | 约 913 周,含 300 题正式标注、盲测、论文和答辩缓冲 | | 当前预计剩余工期 | 约 913 周,含 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 无 egressgateway 无 Secret 并仅发布回环端口;提供真实数据库 readiness、Swagger 和只读 synthetic demo 检索。 - [x] 启动最小 FastAPI `api + gateway`,内部 API 无 egressgateway 无 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` 镜像构建并达到 healthylive/ready/meta/demo status/demo search 均通过真实 HTTP 验收。 - [x] `api + gateway` 镜像构建并达到 healthylive/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/20citation 无重复。
- `vector` 扩展与 HNSW 基线存在app/migrator 均非超级用户app 无 `rag` schema DDL 权限但具有所需 DML。 - `vector` 扩展与 HNSW 基线存在app/migrator 均非超级用户app 无 `rag` schema DDL 权限但具有所需 DML。
- 两次离线 seed 均输出 20/20/209 个可回答虚构问题 Hit@3 = 9/9。 - 两次离线 seed 均输出 20/20/209 个可回答虚构问题 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`
- **预计工期:** 34 个工作日 - **预计工期:** 34 个工作日
- **依赖:** 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不把当前代理分块测试误称为聊天功能完成。

View File

@@ -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/dataReact/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/dataReact/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`

View File

@@ -4,10 +4,12 @@
- Stage 0“仓库、安全和设计基线”已完成并推送。 - Stage 0“仓库、安全和设计基线”已完成并推送。
- Stage 1“模型与数据库 PoC”正在进行。 - Stage 1“模型与数据库 PoC”正在进行。
- 当前整体完成度约 12%,合理区间为 10%15%。 - 当前整体里程碑仍约 12%,合理区间为 10%15%;新增代码不在阶段验收前提前计入完成度
- 0003 的设计基线已完成;业务代码完成度约 18%Stage 1 安全离线子阶段约 95% - 0003 的设计基线已完成;后端离线链路、20 条 synthetic 数据、React 离线演示和 Nginx 单入口已验收
- 后端离线链路、20 条 synthetic 数据、React 离线演示、Nginx 单入口和四网络隔离均已验收;真实百炼三模型 smoke 仍待旧 Key 撤销并轮换 - 五层网络与独立 `model-gateway` 已实现:只有该服务持百炼 Key/egressAPI、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) 为准。
## 文档列表 ## 文档列表

View 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 healthWorker 额外允许 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 为 110 条;返回 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 profileFake 与 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 全部通过后,才能宣称在线百炼模型边界可用。

View File

@@ -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、模型出口与数据库感知服务。

View File

@@ -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"
@@ -40,6 +42,10 @@ if [[ "${1:-}" == "--with-bailian" ]]; then
chmod 600 "$bailian_path" chmod 600 "$bailian_path"
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
if [[ -s "$secret_dir/bailian_api_key" ]]; then
printf 'Existing local Bailian secret was not modified.\n'
else else
printf 'Bailian key not created. Rotate the exposed key, then rerun with --with-bailian.\n' printf 'Bailian key not created. Rotate the exposed key, then rerun with --with-bailian.\n'
fi fi
fi