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

@@ -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")
max_upload_mb: int = Field(default=100, ge=1, le=2048)
model_gateway_base_url: str = "http://model-gateway:8000"
model_gateway_token_file: Path = Path("/run/secrets/model_gateway_api_token")
model_gateway_caller: Literal["api", "worker"] = "api"
model_gateway_timeout_seconds: float = Field(default=120, gt=0, le=600)
bailian_openai_base_url: str = (
"https://<workspace-id>.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
)
@@ -71,6 +76,24 @@ class Settings(BaseSettings):
def normalize_base_url(cls, value: str) -> str:
return value.rstrip("/")
@field_validator("model_gateway_base_url")
@classmethod
def validate_model_gateway_base_url(cls, value: str) -> str:
normalized = value.rstrip("/")
parsed = urlsplit(normalized)
if (
parsed.scheme != "http"
or parsed.hostname != "model-gateway"
or parsed.port != 8000
or parsed.path not in ("", "/")
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError("MODEL_GATEWAY_BASE_URL must be the fixed internal service URL")
return normalized
@field_validator("embedding_dimension", mode="before")
@classmethod
def parse_embedding_dimension(cls, value: object) -> int:

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
@@ -9,26 +9,17 @@ from fastapi import FastAPI, Response, status
from app import __version__
from app.api.v1 import demo_router
from app.core.config import get_settings
from app.core.problems import ApiProblem, api_problem_handler
from app.core.request_context import trace_request
from app.core.secrets import SecretFileError
app = FastAPI(title="Geological RAG API", version=__version__)
app.include_router(demo_router)
type HealthPayload = dict[str, str | dict[str, str]]
@app.get("/health/live", tags=["health"])
@app.get("/api/v1/health/live", tags=["health"])
def live() -> dict[str, str]:
return {"status": "ok", "version": __version__}
@app.get(
"/health/ready",
tags=["health"],
responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Database unavailable"}},
)
@app.get("/api/v1/health/ready", tags=["health"])
def ready(response: Response) -> HealthPayload:
settings = get_settings()
try:
@@ -48,7 +39,6 @@ def ready(response: Response) -> HealthPayload:
return {"status": "ready", "checks": {"database": "ok"}}
@app.get("/api/v1/meta", tags=["meta"])
def meta() -> dict[str, Any]:
settings = get_settings()
return {
@@ -63,6 +53,64 @@ def meta() -> dict[str, Any]:
}
def create_app() -> FastAPI:
"""Create the API without opening a database or loading model credentials."""
api = FastAPI(
title="Geological RAG API",
version=__version__,
openapi_tags=[
{"name": "health", "description": "Process and database health probes."},
{"name": "meta", "description": "Safe runtime capability metadata."},
{"name": "offline-demo", "description": "Synthetic offline validation only."},
],
)
api.middleware("http")(trace_request)
api.add_exception_handler(ApiProblem, api_problem_handler) # type: ignore[arg-type]
api.include_router(demo_router)
api.add_api_route(
"/health/live",
live,
methods=["GET"],
tags=["health"],
include_in_schema=False,
)
api.add_api_route(
"/api/v1/health/live",
live,
methods=["GET"],
tags=["health"],
operation_id="getLiveness",
)
api.add_api_route(
"/health/ready",
ready,
methods=["GET"],
tags=["health"],
include_in_schema=False,
responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Database unavailable"}},
)
api.add_api_route(
"/api/v1/health/ready",
ready,
methods=["GET"],
tags=["health"],
operation_id="getReadiness",
responses={status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Database unavailable"}},
)
api.add_api_route(
"/api/v1/meta",
meta,
methods=["GET"],
tags=["meta"],
operation_id="getRuntimeMetadata",
)
return api
app = create_app()
if __name__ == "__main__":
# Compose publishes this listener only on the host loopback interface.
uvicorn.run("app.main:app", host="0.0.0.0", port=8000) # noqa: S104

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 typing import Any
from app.adapters.bailian import (
BailianChatAdapter,
BailianEmbeddingAdapter,
BailianRerankerAdapter,
)
from app.adapters.model_gateway import ModelGatewayAdapter
from app.core.config import Settings
from app.core.secrets import SecretFileError
from app.ports.model_providers import ChatMessage, ModelProviderError
@@ -30,89 +26,59 @@ class ProbeResult:
status_code: int | None = None
async def probe_embedding(settings: Settings, api_key: str) -> ProbeResult:
adapter = BailianEmbeddingAdapter(
api_key=api_key,
base_url=settings.bailian_openai_base_url,
model=settings.embedding_model,
dimensions=settings.embedding_dimension,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
async def probe_embedding(settings: Settings, adapter: ModelGatewayAdapter) -> ProbeResult:
# API identity probes query embedding. Document embedding remains worker-only.
result = await adapter.embed_query("用于能力探测的虚构地质问题。")
if len(result.vectors) != 1 or len(result.vectors[0]) != settings.embedding_dimension:
raise RuntimeError("embedding contract mismatch")
return ProbeResult(
capability="embedding",
status="ok",
model=result.model,
elapsed_ms=round(result.elapsed_ms, 2),
request_id=result.request_id,
)
try:
result = await adapter.embed_documents(["用于能力探测的虚构地质文本。"])
if len(result.vectors) != 1 or len(result.vectors[0]) != settings.embedding_dimension:
raise RuntimeError("embedding contract mismatch")
return ProbeResult(
capability="embedding",
status="ok",
model=result.model,
elapsed_ms=round(result.elapsed_ms, 2),
request_id=result.request_id,
)
finally:
await adapter.aclose()
async def probe_rerank(settings: Settings, api_key: str) -> ProbeResult:
adapter = BailianRerankerAdapter(
api_key=api_key,
base_url=settings.bailian_rerank_base_url,
model=settings.rerank_model,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
async def probe_rerank(_: Settings, adapter: ModelGatewayAdapter) -> ProbeResult:
result = await adapter.rerank(
"哪段文本提到了斑岩铜矿?",
["虚构斑岩铜矿具有钾化带。", "虚构煤层采用测井曲线对比。"],
top_n=1,
)
try:
result = await adapter.rerank(
"哪段文本提到了斑岩铜矿?",
["虚构斑岩铜矿具有钾化带。", "虚构煤层采用测井曲线对比。"],
top_n=1,
)
if len(result.items) != 1 or result.items[0].index not in (0, 1):
raise RuntimeError("rerank contract mismatch")
return ProbeResult(
capability="rerank",
status="ok",
model=result.model,
elapsed_ms=round(result.elapsed_ms, 2),
request_id=result.request_id,
)
finally:
await adapter.aclose()
async def probe_chat(settings: Settings, api_key: str) -> ProbeResult:
adapter = BailianChatAdapter(
api_key=api_key,
base_url=settings.bailian_openai_base_url,
model=settings.llm_model,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
if len(result.items) != 1 or result.items[0].index not in (0, 1):
raise RuntimeError("rerank contract mismatch")
return ProbeResult(
capability="rerank",
status="ok",
model=result.model,
elapsed_ms=round(result.elapsed_ms, 2),
request_id=result.request_id,
)
async def probe_chat(_: Settings, adapter: ModelGatewayAdapter) -> ProbeResult:
model: str | None = None
request_id: str | None = None
elapsed_ms = 0.0
content_seen = False
try:
async for event in adapter.stream(
[ChatMessage(role="user", content="只回复:能力正常")],
max_tokens=16,
):
model = event.model
request_id = event.request_id or request_id
elapsed_ms = max(elapsed_ms, event.elapsed_ms)
content_seen = content_seen or bool(event.delta)
if not content_seen:
raise RuntimeError("chat stream contained no text")
return ProbeResult(
capability="chat",
status="ok",
model=model,
elapsed_ms=round(elapsed_ms, 2),
request_id=request_id,
)
finally:
await adapter.aclose()
async for event in adapter.stream(
[ChatMessage(role="user", content="只回复:能力正常")],
max_tokens=16,
):
model = event.model
request_id = event.request_id or request_id
elapsed_ms = max(elapsed_ms, event.elapsed_ms)
content_seen = content_seen or bool(event.delta)
if not content_seen:
raise RuntimeError("chat stream contained no text")
return ProbeResult(
capability="chat",
status="ok",
model=model,
elapsed_ms=round(elapsed_ms, 2),
request_id=request_id,
)
def failed_probe(capability: str, error: BaseException) -> ProbeResult:
@@ -133,12 +99,12 @@ def failed_probe(capability: str, error: BaseException) -> ProbeResult:
async def run_probe(
capability: str,
operation: Callable[[Settings, str], Awaitable[ProbeResult]],
operation: Callable[[Settings, ModelGatewayAdapter], Awaitable[ProbeResult]],
settings: Settings,
api_key: str,
adapter: ModelGatewayAdapter,
) -> ProbeResult:
try:
return await operation(settings, api_key)
return await operation(settings, adapter)
except Exception as exc: # The output is deliberately reduced to a safe category.
return failed_probe(capability, exc)
@@ -148,17 +114,10 @@ def write_json_line(payload: dict[str, Any]) -> None:
async def async_main() -> int:
adapter: ModelGatewayAdapter | None = None
try:
settings = Settings()
if any(
"<workspace-id>" in url
for url in (
settings.bailian_openai_base_url,
settings.bailian_rerank_base_url,
)
):
raise ValueError("workspace endpoint placeholders are not runnable")
api_key = settings.bailian_api_key()
adapter = ModelGatewayAdapter.from_settings(settings)
except (SecretFileError, ValueError):
write_json_line(
{
@@ -174,12 +133,15 @@ async def async_main() -> int:
("rerank", probe_rerank),
("chat", probe_chat),
)
results = []
for capability, operation in probes:
result = await run_probe(capability, operation, settings, api_key)
results.append(result)
write_json_line(asdict(result))
return 0 if all(result.status == "ok" for result in results) else 1
try:
results = []
for capability, operation in probes:
result = await run_probe(capability, operation, settings, adapter)
results.append(result)
write_json_line(asdict(result))
return 0 if all(result.status == "ok" for result in results) else 1
finally:
await adapter.aclose()
def main() -> None:

View File

@@ -20,8 +20,8 @@ from pgvector.psycopg import register_vector
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.adapters.bailian import BailianEmbeddingAdapter, BailianRerankerAdapter
from app.adapters.fake import FakeEmbeddingProvider, FakeReranker, lexical_features
from app.adapters.model_gateway import ModelGatewayAdapter
from app.core.config import Settings
from app.core.demo_identity import (
ACCESS_SCOPE_ID,
@@ -55,6 +55,42 @@ class DemoQuery:
answerable: bool
@dataclass(frozen=True, slots=True)
class DemoNamespace:
mode: str
knowledge_base_id: uuid.UUID
access_scope_id: uuid.UUID
scope_name: str
knowledge_base_name: str
storage_prefix: str
OFFLINE_NAMESPACE = DemoNamespace(
mode="fake",
knowledge_base_id=KNOWLEDGE_BASE_ID,
access_scope_id=ACCESS_SCOPE_ID,
scope_name="synthetic-demo",
knowledge_base_name="虚构地质 PoC 知识库(离线)",
storage_prefix="synthetic/offline",
)
BAILIAN_NAMESPACE = DemoNamespace(
mode="bailian",
knowledge_base_id=uuid.uuid5(IDENTITY_NAMESPACE, "synthetic-bailian-knowledge-base"),
access_scope_id=uuid.uuid5(IDENTITY_NAMESPACE, "synthetic-bailian-public-scope"),
scope_name="synthetic-bailian-validation",
knowledge_base_name="虚构地质 PoC 知识库(百炼验证)",
storage_prefix="synthetic/bailian",
)
@dataclass(frozen=True, slots=True)
class EmbeddedVector:
vector: tuple[float, ...]
request_id: str | None
usage: dict[str, int | None]
elapsed_ms: int
@dataclass(frozen=True, slots=True)
class PreparedChunk:
source_id: str
@@ -71,6 +107,9 @@ class PreparedChunk:
embedding_profile_hash: str
vector: tuple[float, ...]
embedding_model: str
provider_request_id: str | None
embedding_usage: dict[str, int | None]
embedding_elapsed_ms: int
title: str
region: str
mineral: str
@@ -88,6 +127,14 @@ def sha256_text(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def namespace_for_mode(mode: str) -> DemoNamespace:
if mode == "fake":
return OFFLINE_NAMESPACE
if mode == "bailian":
return BAILIAN_NAMESPACE
raise SeedContractError("invalid_provider_mode")
def load_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.is_file():
raise SeedContractError("fixture_missing")
@@ -142,8 +189,10 @@ def load_queries(path: Path) -> list[DemoQuery]:
def embedding_profile_hash(settings: Settings, mode: str) -> str:
if mode != "bailian":
if mode == "fake":
return offline_embedding_profile_hash(settings.embedding_dimension)
if mode != "bailian":
raise SeedContractError("invalid_provider_mode")
endpoint_identity = sha256_text(urlsplit(settings.bailian_openai_base_url).hostname or "")
model = settings.embedding_model
@@ -164,15 +213,30 @@ def embedding_profile_hash(settings: Settings, mode: str) -> str:
async def embed_in_batches(
provider: EmbeddingProvider,
texts: Sequence[str],
) -> tuple[tuple[tuple[float, ...], ...], str]:
vectors: list[tuple[float, ...]] = []
) -> tuple[tuple[EmbeddedVector, ...], str]:
vectors: list[EmbeddedVector] = []
resolved_model: str | None = None
for offset in range(0, len(texts), 10):
result = await provider.embed_documents(texts[offset : offset + 10])
if resolved_model is not None and result.model != resolved_model:
raise SeedContractError("embedding_model_changed_between_batches")
resolved_model = result.model
vectors.extend(result.vectors)
if len(result.vectors) != len(texts[offset : offset + 10]):
raise SeedContractError("embedding_batch_count_mismatch")
usage = {
"input_tokens": result.usage.input_tokens,
"output_tokens": result.usage.output_tokens,
"total_tokens": result.usage.total_tokens,
}
vectors.extend(
EmbeddedVector(
vector=vector,
request_id=result.request_id,
usage=usage,
elapsed_ms=max(0, round(result.elapsed_ms)),
)
for vector in result.vectors
)
if len(vectors) != len(texts) or resolved_model is None:
raise SeedContractError("embedding_result_count_mismatch")
return tuple(vectors), resolved_model
@@ -180,10 +244,11 @@ async def embed_in_batches(
def prepare_chunks(
documents: Sequence[DemoDocument],
vectors: Sequence[tuple[float, ...]],
vectors: Sequence[EmbeddedVector],
*,
profile_hash: str,
embedding_model: str,
namespace: DemoNamespace = OFFLINE_NAMESPACE,
) -> list[PreparedChunk]:
prepared = []
for document, vector in zip(documents, vectors, strict=True):
@@ -200,7 +265,12 @@ def prepare_chunks(
separators=(",", ":"),
)
raw_hash = sha256_text(raw_payload)
document_id = uuid.uuid5(IDENTITY_NAMESPACE, f"document:{document.source_id}")
document_identity = (
f"document:{document.source_id}"
if namespace.mode == "fake"
else f"document:{namespace.mode}:{document.source_id}"
)
document_id = uuid.uuid5(IDENTITY_NAMESPACE, document_identity)
version_id = uuid.uuid5(
IDENTITY_NAMESPACE,
f"version:{document.source_id}:{raw_hash}:{profile_hash}",
@@ -237,8 +307,11 @@ def prepare_chunks(
embedding_text_sha256=embedding_hash,
outbound_manifest_sha256=sha256_text(manifest_payload),
embedding_profile_hash=profile_hash,
vector=vector,
vector=vector.vector,
embedding_model=embedding_model,
provider_request_id=vector.request_id,
embedding_usage=vector.usage,
embedding_elapsed_ms=vector.elapsed_ms,
title=document.title,
region=document.region,
mineral=document.mineral,
@@ -255,20 +328,96 @@ def database_dsn(settings: Settings) -> str:
)
def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[str, int]:
def write_chunks(
settings: Settings,
chunks: Sequence[PreparedChunk],
*,
namespace: DemoNamespace,
) -> dict[str, int]:
if not chunks:
raise SeedContractError("chunks_empty")
profile_hashes = {item.embedding_profile_hash for item in chunks}
resolved_models = {item.embedding_model for item in chunks}
if len(profile_hashes) != 1 or len(resolved_models) != 1:
raise SeedContractError("mixed_embedding_profiles")
profile_hash = next(iter(profile_hashes))
resolved_model = next(iter(resolved_models))
if namespace.mode == "fake":
provider = "local-synthetic"
api_mode = "deterministic-offline"
endpoint_identity_hash = sha256_text("local-fake")
else:
provider = "aliyun-bailian"
api_mode = "model-gateway/openai-compatible"
endpoint_identity_hash = sha256_text(
urlsplit(settings.bailian_openai_base_url).hostname or ""
)
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
register_vector(connection)
connection.execute("SELECT pg_advisory_xact_lock(724202607120001)")
connection.execute(
"""
INSERT INTO rag.knowledge_bases (id, name, description)
VALUES (%s, %s, %s)
INSERT INTO rag.model_profiles (
profile_hash, alias, kind, provider, model, api_mode, dimension,
endpoint_identity_hash, config_snapshot, synthetic, enabled
) VALUES (
%s, %s, 'embedding', %s, %s, %s, 1024, %s, %s, %s, true
)
ON CONFLICT (profile_hash) DO NOTHING
""",
(
profile_hash,
f"{namespace.mode}-embedding-{profile_hash[:12]}",
provider,
resolved_model,
api_mode,
endpoint_identity_hash,
Jsonb(
{
"dimension": settings.embedding_dimension,
"requested_model": settings.embedding_model,
"source": "synthetic-seed-v1",
}
),
namespace.mode == "fake",
),
)
registered_profile = connection.execute(
"""
SELECT kind, provider, model, api_mode, dimension, endpoint_identity_hash
FROM rag.model_profiles
WHERE profile_hash = %s
""",
(profile_hash,),
).fetchone()
if registered_profile is None or (
registered_profile["kind"] != "embedding"
or registered_profile["provider"] != provider
or registered_profile["model"] != resolved_model
or registered_profile["api_mode"] != api_mode
or registered_profile["dimension"] != settings.embedding_dimension
or registered_profile["endpoint_identity_hash"] != endpoint_identity_hash
):
raise SeedContractError("embedding_profile_collision")
connection.execute(
"""
INSERT INTO rag.knowledge_bases (
id, name, description, active_embedding_profile_hash
)
VALUES (%s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
active_embedding_profile_hash = EXCLUDED.active_embedding_profile_hash,
updated_at = now()
""",
(KNOWLEDGE_BASE_ID, "虚构地质 PoC 知识库", "仅含公开的合成验证文本"),
(
namespace.knowledge_base_id,
namespace.knowledge_base_name,
"仅含公开的合成验证文本",
profile_hash,
),
)
connection.execute(
"""
@@ -276,7 +425,11 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
VALUES (%s, %s, %s)
ON CONFLICT (id) DO NOTHING
""",
(ACCESS_SCOPE_ID, KNOWLEDGE_BASE_ID, "synthetic-demo"),
(
namespace.access_scope_id,
namespace.knowledge_base_id,
namespace.scope_name,
),
)
for item in chunks:
@@ -295,11 +448,11 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
""",
(
item.document_id,
KNOWLEDGE_BASE_ID,
ACCESS_SCOPE_ID,
namespace.knowledge_base_id,
namespace.access_scope_id,
item.raw_sha256,
f"{item.source_id}.json",
f"synthetic/{item.source_id}",
f"{namespace.storage_prefix}/{item.source_id}",
),
)
connection.execute(
@@ -384,10 +537,10 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
""",
(
item.chunk_id,
KNOWLEDGE_BASE_ID,
namespace.knowledge_base_id,
item.document_id,
item.version_id,
ACCESS_SCOPE_ID,
namespace.access_scope_id,
item.cloud_text,
item.cloud_text,
item.cloud_text_sha256,
@@ -425,6 +578,41 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
""",
(item.version_id,),
)
connection.execute(
"""
INSERT INTO rag.embedding_cache (
profile_hash, embedding_text_sha256, embedding, resolved_model,
provider_request_id, usage, elapsed_ms
) VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (profile_hash, embedding_text_sha256) DO NOTHING
""",
(
item.embedding_profile_hash,
item.embedding_text_sha256,
Vector(list(item.vector)),
item.embedding_model,
item.provider_request_id,
Jsonb(item.embedding_usage),
item.embedding_elapsed_ms,
),
)
connection.execute(
"""
INSERT INTO rag.chunk_embedding_assignments (
chunk_id, profile_hash, embedding_text_sha256,
cache_profile_hash, cache_embedding_text_sha256,
status, completed_at
) VALUES (%s, %s, %s, %s, %s, 'READY', now())
ON CONFLICT (chunk_id, profile_hash) DO NOTHING
""",
(
item.chunk_id,
item.embedding_profile_hash,
item.embedding_text_sha256,
item.embedding_profile_hash,
item.embedding_text_sha256,
),
)
connection.execute(
"""
UPDATE rag.chunks
@@ -459,8 +647,9 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
count(*) FILTER (WHERE searchable)::integer AS searchable
FROM rag.chunks
WHERE knowledge_base_id = %s
AND embedding_profile_hash = %s
""",
(KNOWLEDGE_BASE_ID,),
(namespace.knowledge_base_id, profile_hash),
).fetchone()
if counts is None:
raise SeedContractError("database_count_missing")
@@ -470,6 +659,9 @@ def write_chunks(settings: Settings, chunks: Sequence[PreparedChunk]) -> dict[st
def retrieve(
settings: Settings,
query_vector: tuple[float, ...],
*,
namespace: DemoNamespace,
profile_hash: str,
) -> list[dict[str, Any]]:
with psycopg.connect(database_dsn(settings), row_factory=dict_row) as connection:
register_vector(connection)
@@ -477,19 +669,25 @@ def retrieve(
connection.execute("SET LOCAL hnsw.ef_search = 100")
rows = connection.execute(
"""
SELECT id, metadata, embedding_text,
1 - (embedding <=> %s) AS vector_score
FROM rag.chunks
WHERE searchable
AND knowledge_base_id = %s
AND access_scope_id = %s
ORDER BY embedding <=> %s
SELECT chunk.id, chunk.metadata, chunk.embedding_text,
1 - (chunk.embedding <=> %s) AS vector_score
FROM rag.chunks AS chunk
JOIN rag.knowledge_bases AS knowledge_base
ON knowledge_base.id = chunk.knowledge_base_id
AND knowledge_base.active_embedding_profile_hash = %s
WHERE chunk.searchable
AND chunk.knowledge_base_id = %s
AND chunk.access_scope_id = %s
AND chunk.embedding_profile_hash = %s
ORDER BY chunk.embedding <=> %s
LIMIT %s
""",
(
Vector(list(query_vector)),
KNOWLEDGE_BASE_ID,
ACCESS_SCOPE_ID,
profile_hash,
namespace.knowledge_base_id,
namespace.access_scope_id,
profile_hash,
Vector(list(query_vector)),
settings.vector_top_k,
),
@@ -502,12 +700,20 @@ async def evaluate_queries(
queries: Sequence[DemoQuery],
embedder: EmbeddingProvider,
reranker: Reranker,
*,
namespace: DemoNamespace,
profile_hash: str,
) -> dict[str, float | int]:
hits = 0
answerable = 0
for query in queries:
query_result = await embedder.embed_query(query.query)
candidates = retrieve(settings, query_result.vectors[0])
candidates = retrieve(
settings,
query_result.vectors[0],
namespace=namespace,
profile_hash=profile_hash,
)
if not candidates:
continue
reranked = await reranker.rerank(
@@ -552,14 +758,14 @@ async def async_main() -> int:
return 2
settings = Settings()
namespace = namespace_for_mode(mode)
documents_path = Path(
os.getenv("DEMO_DOCUMENTS_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_documents.jsonl"))
)
queries_path = Path(
os.getenv("DEMO_QUERIES_PATH", str(DEFAULT_SAMPLE_ROOT / "demo_queries.jsonl"))
)
cloud_embedder: BailianEmbeddingAdapter | None = None
cloud_reranker: BailianRerankerAdapter | None = None
cloud_gateway: ModelGatewayAdapter | None = None
try:
documents = load_documents(documents_path)
queries = load_queries(queries_path)
@@ -567,24 +773,9 @@ async def async_main() -> int:
embedder: EmbeddingProvider
reranker: Reranker
if mode == "bailian":
api_key = settings.bailian_api_key()
cloud_embedder = BailianEmbeddingAdapter(
api_key=api_key,
base_url=settings.bailian_openai_base_url,
model=settings.embedding_model,
dimensions=settings.embedding_dimension,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
)
cloud_reranker = BailianRerankerAdapter(
api_key=api_key,
base_url=settings.bailian_rerank_base_url,
model=settings.rerank_model,
timeout_seconds=settings.model_timeout_seconds,
max_retries=settings.model_max_retries,
)
embedder = cloud_embedder
reranker = cloud_reranker
cloud_gateway = ModelGatewayAdapter.from_settings(settings)
embedder = cloud_gateway
reranker = cloud_gateway
else:
embedder = FakeEmbeddingProvider(settings.embedding_dimension)
reranker = FakeReranker()
@@ -599,9 +790,17 @@ async def async_main() -> int:
vectors,
profile_hash=profile_hash,
embedding_model=resolved_model,
namespace=namespace,
)
counts = write_chunks(settings, prepared, namespace=namespace)
metrics = await evaluate_queries(
settings,
queries,
embedder,
reranker,
namespace=namespace,
profile_hash=profile_hash,
)
counts = write_chunks(settings, prepared)
metrics = await evaluate_queries(settings, queries, embedder, reranker)
output_summary(
{
"counts": counts,
@@ -654,10 +853,8 @@ async def async_main() -> int:
)
return 1
finally:
if cloud_embedder is not None:
await cloud_embedder.aclose()
if cloud_reranker is not None:
await cloud_reranker.aclose()
if cloud_gateway is not None:
await cloud_gateway.aclose()
def main() -> None:

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")
migrate = _service_block("migrate")
api = _service_block("api")
model_gateway = _service_block("model-gateway")
gateway = _service_block("gateway")
web = _service_block("web")
provider_smoke = _service_block("provider-smoke")
@@ -51,20 +52,37 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
assert "postgres_app_password" not in migrate
assert "postgres_app_password" in api
assert "model_gateway_api_token" in api
assert "postgres_bootstrap_password" not in api
assert "postgres_migrator_password" not in api
assert "bailian_api_key" not in api
assert '"127.0.0.1:8000:8000"' not in api
assert " - data" in api
assert " - model" in api
assert " - edge" not in api
assert " - egress" not in api
assert "read_only: true" in api
assert "no-new-privileges:true" in api
assert "cap_drop:" in api and " - ALL" in api
assert "bailian_api_key" in model_gateway
assert "model_gateway_api_token" in model_gateway
assert "model_gateway_worker_token" in model_gateway
assert "postgres_" not in model_gateway
assert " - model" in model_gateway
assert " - egress" in model_gateway
assert " - data" not in model_gateway
assert " - edge" not in model_gateway
assert " - ingress" not in model_gateway
assert "ports:" not in model_gateway
assert "read_only: true" in model_gateway
assert "no-new-privileges:true" in model_gateway
assert "cap_drop:" in model_gateway and " - ALL" in model_gateway
assert '"127.0.0.1:8000:8000"' not in gateway
assert " - ingress" in gateway
assert " - data" in gateway
assert " - model" not in gateway
assert " - edge" not in gateway
assert " - egress" not in gateway
assert "secrets:" not in gateway
@@ -77,6 +95,7 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
assert " - edge" in web
assert " - ingress" in web
assert " - data" not in web
assert " - model" not in web
assert " - egress" not in web
assert "secrets:" not in web
assert "POSTGRES_" not in web
@@ -85,13 +104,20 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
assert "no-new-privileges:true" in web
assert len(re.findall(r"(?m)^ ports:$", COMPOSE)) == 1
assert "bailian_api_key" in provider_smoke
assert "bailian_api_key" not in provider_smoke
assert "model_gateway_api_token" in provider_smoke
assert "postgres_" not in provider_smoke
assert " - model" in provider_smoke
assert " - egress" not in provider_smoke
assert "postgres_app_password" in seed_demo
assert "postgres_bootstrap_password" not in seed_demo
assert "postgres_migrator_password" not in seed_demo
assert "bailian_api_key" in seed_demo
assert "bailian_api_key" not in seed_demo
assert "model_gateway_worker_token" in seed_demo
assert "MODEL_GATEWAY_CALLER: worker" in seed_demo
assert " - model" in seed_demo
assert " - egress" not in seed_demo
assert "./data/samples/public:/demo:ro" in seed_demo
assert "postgres_app_password" in seed_demo_offline
@@ -101,6 +127,7 @@ def test_compose_isolates_database_credentials_and_networks() -> None:
assert re.search(r"(?ms)^ data:\n.*?^ internal: true$", COMPOSE)
assert re.search(r"(?ms)^ ingress:\n.*?^ internal: true$", COMPOSE)
assert re.search(r"(?ms)^ model:\n.*?^ internal: true$", COMPOSE)
assert re.search(r"(?m)^ edge:$", COMPOSE)
assert re.search(r"(?m)^ egress:$", COMPOSE)

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")
def test_model_gateway_url_is_fixed_to_internal_service() -> None:
settings = Settings(model_gateway_base_url="http://model-gateway:8000/")
assert settings.model_gateway_base_url == "http://model-gateway:8000"
@pytest.mark.parametrize(
"value",
[
"https://model-gateway:8000",
"http://model-gateway:9000",
"http://127.0.0.1:8000",
"http://model-gateway:8000/proxy",
"http://user:model-token@model-gateway:8000",
],
)
def test_model_gateway_url_rejects_ssrf_and_credential_variants(value: str) -> None:
with pytest.raises(ValueError, match="fixed internal service URL"):
Settings(model_gateway_base_url=value)
def test_embedding_dimension_accepts_compose_string(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("EMBEDDING_DIMENSION", "1024")

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
from app.adapters.model_gateway import ModelGatewayAdapter
from app.core.config import Settings
from app.ports.model_providers import ModelProviderError, ProviderErrorKind
from app.tools import provider_smoke
@@ -19,28 +20,23 @@ def test_provider_smoke_settings_accept_compose_embedding_dimension(
async def test_provider_smoke_entrypoint_accepts_compose_embedding_dimension(
monkeypatch: pytest.MonkeyPatch,
) -> None:
workspace_host = "workspace-test.cn-beijing.maas.aliyuncs.com"
monkeypatch.setenv("EMBEDDING_DIMENSION", "1024")
monkeypatch.setenv(
"BAILIAN_OPENAI_BASE_URL",
f"https://{workspace_host}/compatible-mode/v1",
)
monkeypatch.setenv(
"BAILIAN_RERANK_BASE_URL",
f"https://{workspace_host}/compatible-api/v1",
)
observed_dimensions: list[int] = []
def fake_api_key(settings: Settings) -> str:
observed_dimensions.append(settings.embedding_dimension)
return "test-only-api-key"
class StubGateway:
async def aclose(self) -> None:
return None
async def successful_probe(settings: Settings, api_key: str) -> ProbeResult:
def fake_gateway(settings: Settings) -> StubGateway:
observed_dimensions.append(settings.embedding_dimension)
assert api_key == "test-only-api-key"
return StubGateway()
async def successful_probe(settings: Settings, adapter: StubGateway) -> ProbeResult:
observed_dimensions.append(settings.embedding_dimension)
assert isinstance(adapter, StubGateway)
return ProbeResult(capability="test", status="ok")
monkeypatch.setattr(Settings, "bailian_api_key", fake_api_key)
monkeypatch.setattr(ModelGatewayAdapter, "from_settings", staticmethod(fake_gateway))
monkeypatch.setattr(provider_smoke, "probe_embedding", successful_probe)
monkeypatch.setattr(provider_smoke, "probe_rerank", successful_probe)
monkeypatch.setattr(provider_smoke, "probe_chat", successful_probe)

View File

@@ -5,9 +5,12 @@ import pytest
from app.adapters.fake import FakeEmbeddingProvider
from app.core.config import Settings
from app.tools.seed_demo import (
BAILIAN_NAMESPACE,
OFFLINE_NAMESPACE,
embed_in_batches,
embedding_profile_hash,
load_documents,
namespace_for_mode,
prepare_chunks,
)
@@ -38,6 +41,44 @@ async def test_seed_preparation_batches_and_hash_binds_twenty_documents() -> Non
assert all(item.embedding_text == item.embedding_prefix + item.cloud_text for item in chunks)
assert len({item.chunk_id for item in chunks}) == 20
assert all(len(item.outbound_manifest_sha256) == 64 for item in chunks)
assert all(item.embedding_elapsed_ms >= 0 for item in chunks)
@pytest.mark.asyncio
async def test_live_and_offline_seed_namespaces_cannot_share_document_identity() -> None:
documents = load_documents(DOCUMENTS_PATH)
settings = Settings()
vectors, model = await embed_in_batches(
FakeEmbeddingProvider(),
[f"标题:{item.title}\n正文:{item.content}" for item in documents],
)
profile_hash = embedding_profile_hash(settings, "fake")
offline = prepare_chunks(
documents,
vectors,
profile_hash=profile_hash,
embedding_model=model,
namespace=OFFLINE_NAMESPACE,
)
live = prepare_chunks(
documents,
vectors,
profile_hash=profile_hash,
embedding_model=model,
namespace=BAILIAN_NAMESPACE,
)
assert OFFLINE_NAMESPACE.knowledge_base_id != BAILIAN_NAMESPACE.knowledge_base_id
assert OFFLINE_NAMESPACE.access_scope_id != BAILIAN_NAMESPACE.access_scope_id
assert {item.document_id for item in offline}.isdisjoint({item.document_id for item in live})
assert namespace_for_mode("fake") is OFFLINE_NAMESPACE
assert namespace_for_mode("bailian") is BAILIAN_NAMESPACE
def test_seed_rejects_unknown_provider_namespace() -> None:
with pytest.raises(ValueError, match="invalid_provider_mode"):
namespace_for_mode("unknown")
def test_seed_rejects_fixture_not_explicitly_marked_synthetic(tmp_path: Path) -> None: