From 75592af33a47e281b64006836383c20947c162b6 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 13 Jul 2026 04:09:06 +0800 Subject: [PATCH] Isolate cloud model access before enabling product RAG workflows 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 --- .env.example | 5 + backend/app/adapters/model_gateway.py | 539 +++++++++++++ backend/app/core/config.py | 23 + backend/app/core/problems.py | 62 ++ backend/app/core/request_context.py | 30 + backend/app/main.py | 74 +- backend/app/model_gateway.py | 755 ++++++++++++++++++ backend/app/tools/provider_smoke.py | 156 ++-- backend/app/tools/seed_demo.py | 307 +++++-- .../0002_model_profiles_and_invocations.py | 430 ++++++++++ .../test_model_profile_migration_contract.py | 223 ++++++ .../tests/integration/test_schema_contract.py | 31 +- .../tests/unit/test_application_contract.py | 65 ++ backend/tests/unit/test_config_and_secrets.py | 21 + backend/tests/unit/test_model_gateway.py | 748 +++++++++++++++++ .../tests/unit/test_model_gateway_client.py | 275 +++++++ backend/tests/unit/test_provider_smoke.py | 26 +- backend/tests/unit/test_seed_demo.py | 41 + compose.yaml | 81 +- docs/00-overall-design.md | 57 +- docs/01-data-and-evaluation.md | 2 +- docs/02-deployment-and-security.md | 91 ++- docs/04-project-todo.md | 44 +- docs/05-stage1-runbook.md | 13 +- docs/README.md | 10 +- docs/adr/0005-isolate-model-egress.md | 68 ++ docs/adr/README.md | 1 + scripts/init-local-secrets.sh | 8 +- 28 files changed, 3932 insertions(+), 254 deletions(-) create mode 100644 backend/app/adapters/model_gateway.py create mode 100644 backend/app/core/problems.py create mode 100644 backend/app/core/request_context.py create mode 100644 backend/app/model_gateway.py create mode 100644 backend/migrations/versions/0002_model_profiles_and_invocations.py create mode 100644 backend/tests/integration/test_model_profile_migration_contract.py create mode 100644 backend/tests/unit/test_application_contract.py create mode 100644 backend/tests/unit/test_model_gateway.py create mode 100644 backend/tests/unit/test_model_gateway_client.py create mode 100644 docs/adr/0005-isolate-model-egress.md diff --git a/.env.example b/.env.example index 94d72b5..3143648 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,11 @@ POSTGRES_APP_PASSWORD_FILE=/run/secrets/postgres_app_password UPLOAD_ROOT=/data/uploads MAX_UPLOAD_MB=100 +MODEL_GATEWAY_BASE_URL=http://model-gateway:8000 +MODEL_GATEWAY_TOKEN_FILE=/run/secrets/model_gateway_api_token +MODEL_GATEWAY_CALLER=api +MODEL_GATEWAY_TIMEOUT_SECONDS=120 + # The actual workspace host is local deployment configuration and is not committed. BAILIAN_OPENAI_BASE_URL=https://.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 BAILIAN_NATIVE_BASE_URL=https://.cn-beijing.maas.aliyuncs.com/api/v1 diff --git a/backend/app/adapters/model_gateway.py b/backend/app/adapters/model_gateway.py new file mode 100644 index 0000000..871b6e6 --- /dev/null +++ b/backend/app/adapters/model_gateway.py @@ -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, + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ae8b8f9..30fd508 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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://.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: diff --git a/backend/app/core/problems.py b/backend/app/core/problems.py new file mode 100644 index 0000000..f5e1bc9 --- /dev/null +++ b/backend/app/core/problems.py @@ -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, + ), + ) diff --git a/backend/app/core/request_context.py b/backend/app/core/request_context.py new file mode 100644 index 0000000..c2dd1e0 --- /dev/null +++ b/backend/app/core/request_context.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index b5c988f..01fc70d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/model_gateway.py b/backend/app/model_gateway.py new file mode 100644 index 0000000..eb3a654 --- /dev/null +++ b/backend/app/model_gateway.py @@ -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() diff --git a/backend/app/tools/provider_smoke.py b/backend/app/tools/provider_smoke.py index 7ddc0f6..f8f3c91 100644 --- a/backend/app/tools/provider_smoke.py +++ b/backend/app/tools/provider_smoke.py @@ -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( - "" 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: diff --git a/backend/app/tools/seed_demo.py b/backend/app/tools/seed_demo.py index 89a769b..e9ba487 100644 --- a/backend/app/tools/seed_demo.py +++ b/backend/app/tools/seed_demo.py @@ -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: diff --git a/backend/migrations/versions/0002_model_profiles_and_invocations.py b/backend/migrations/versions/0002_model_profiles_and_invocations.py new file mode 100644 index 0000000..4226b24 --- /dev/null +++ b/backend/migrations/versions/0002_model_profiles_and_invocations.py @@ -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;") diff --git a/backend/tests/integration/test_model_profile_migration_contract.py b/backend/tests/integration/test_model_profile_migration_contract.py new file mode 100644 index 0000000..aa10611 --- /dev/null +++ b/backend/tests/integration/test_model_profile_migration_contract.py @@ -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 diff --git a/backend/tests/integration/test_schema_contract.py b/backend/tests/integration/test_schema_contract.py index 7f96789..1c29c21 100644 --- a/backend/tests/integration/test_schema_contract.py +++ b/backend/tests/integration/test_schema_contract.py @@ -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) diff --git a/backend/tests/unit/test_application_contract.py b/backend/tests/unit/test_application_contract.py new file mode 100644 index 0000000..94a8ba9 --- /dev/null +++ b/backend/tests/unit/test_application_contract.py @@ -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"] == [] diff --git a/backend/tests/unit/test_config_and_secrets.py b/backend/tests/unit/test_config_and_secrets.py index 74ff233..97a4707 100644 --- a/backend/tests/unit/test_config_and_secrets.py +++ b/backend/tests/unit/test_config_and_secrets.py @@ -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") diff --git a/backend/tests/unit/test_model_gateway.py b/backend/tests/unit/test_model_gateway.py new file mode 100644 index 0000000..5a4f051 --- /dev/null +++ b/backend/tests/unit/test_model_gateway.py @@ -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" diff --git a/backend/tests/unit/test_model_gateway_client.py b/backend/tests/unit/test_model_gateway_client.py new file mode 100644 index 0000000..21b73f6 --- /dev/null +++ b/backend/tests/unit/test_model_gateway_client.py @@ -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") diff --git a/backend/tests/unit/test_provider_smoke.py b/backend/tests/unit/test_provider_smoke.py index ee78ec9..ad01e91 100644 --- a/backend/tests/unit/test_provider_smoke.py +++ b/backend/tests/unit/test_provider_smoke.py @@ -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) diff --git a/backend/tests/unit/test_seed_demo.py b/backend/tests/unit/test_seed_demo.py index 1eb7ac3..4236002 100644 --- a/backend/tests/unit/test_seed_demo.py +++ b/backend/tests/unit/test_seed_demo.py @@ -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: diff --git a/compose.yaml b/compose.yaml index 12b8fa2..e18db49 100644 --- a/compose.yaml +++ b/compose.yaml @@ -31,6 +31,12 @@ x-rag-config: &rag-config MODEL_MAX_RETRIES: "${MODEL_MAX_RETRIES:-3}" MODEL_MAX_CONCURRENCY: "${MODEL_MAX_CONCURRENCY:-4}" +x-model-client-config: &model-client-config + MODEL_GATEWAY_BASE_URL: http://model-gateway:8000 + MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_api_token + MODEL_GATEWAY_CALLER: api + MODEL_GATEWAY_TIMEOUT_SECONDS: "${MODEL_GATEWAY_TIMEOUT_SECONDS:-120}" + services: db: image: pgvector/pgvector:0.8.2-pg17@sha256:feb68f4f15446397d8cac7f4fe48fe4586de83160d1fc48b46283312d1a33966 @@ -90,11 +96,53 @@ services: condition: service_healthy migrate: condition: service_completed_successfully - environment: *runtime-config + model-gateway: + condition: service_healthy + environment: + <<: [*runtime-config, *model-client-config] secrets: - postgres_app_password + - model_gateway_api_token networks: - data + - model + healthcheck: + test: + - CMD + - python + - -c + - >- + import urllib.request; + urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=2) + interval: 10s + timeout: 3s + retries: 5 + start_period: 5s + init: true + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: unless-stopped + + model-gateway: + build: + context: ./backend + command: ["uvicorn", "app.model_gateway:app", "--host", "0.0.0.0", "--port", "8000"] + environment: + <<: *rag-config + MODEL_GATEWAY_ALLOWED_TOKEN_FILES: >- + api=/run/secrets/model_gateway_api_token,worker=/run/secrets/model_gateway_worker_token + secrets: + - bailian_api_key + - model_gateway_api_token + - model_gateway_worker_token + networks: + - model + - egress healthcheck: test: - CMD @@ -187,11 +235,19 @@ services: context: ./backend command: ["python", "-m", "app.tools.provider_smoke"] profiles: ["tools"] - environment: *rag-config + depends_on: + model-gateway: + condition: service_healthy + environment: + <<: *model-client-config + EMBEDDING_MODEL: ${EMBEDDING_MODEL:-text-embedding-v4} + EMBEDDING_DIMENSION: "${EMBEDDING_DIMENSION:-1024}" + RERANK_MODEL: ${RERANK_MODEL:-qwen3-rerank} + LLM_MODEL: ${LLM_MODEL:-deepseek-v4-flash} secrets: - - bailian_api_key + - model_gateway_api_token networks: - - egress + - model restart: "no" seed-demo: @@ -202,17 +258,21 @@ services: depends_on: migrate: condition: service_completed_successfully + model-gateway: + condition: service_healthy environment: - <<: [*runtime-config, *rag-config] + <<: [*runtime-config, *rag-config, *model-client-config] DEMO_PROVIDER_MODE: bailian DEMO_DOCUMENTS_PATH: /demo/demo_documents.jsonl DEMO_QUERIES_PATH: /demo/demo_queries.jsonl + MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_worker_token + MODEL_GATEWAY_CALLER: worker secrets: - postgres_app_password - - bailian_api_key + - model_gateway_worker_token networks: - data - - egress + - model volumes: - ./data/samples/public:/demo:ro restart: "no" @@ -250,6 +310,9 @@ networks: data: driver: bridge internal: true + model: + driver: bridge + internal: true egress: driver: bridge @@ -260,5 +323,9 @@ secrets: file: ./secrets/postgres_migrator_password postgres_app_password: file: ./secrets/postgres_app_password + model_gateway_api_token: + file: ./secrets/model_gateway_api_token + model_gateway_worker_token: + file: ./secrets/model_gateway_worker_token bailian_api_key: file: ./secrets/bailian_api_key diff --git a/docs/00-overall-design.md b/docs/00-overall-design.md index a2e40d3..193c8e6 100644 --- a/docs/00-overall-design.md +++ b/docs/00-overall-design.md @@ -4,9 +4,9 @@ |---|---| | 课题 | 基于 RAG 的地质找矿知识问答系统构建与应用 | | 学科方向 | 大数据分析 | -| 文档版本 | v1.0-design | -| 状态 | 设计基线,待实现验证 | -| 更新日期 | 2026-07-11 | +| 文档版本 | v1.1-implementation-sync | +| 状态 | 设计基线;安全运行骨架已部分实现,产品主链路未完成 | +| 更新日期 | 2026-07-13 | | 后端 | Python + FastAPI | | 前端 | React + TypeScript | | 向量存储 | PostgreSQL + pgvector | @@ -22,7 +22,7 @@ 1. 仓库、提交历史、镜像层、前端包、测试数据和日志都不能出现真实 Key。 2. 开发环境从未提交的 Docker Secret 文件读取;生产环境从 Secret Manager 或编排平台读取。 -3. 前端永远不接触 Key,所有百炼请求由后端发起。 +3. 前端、入口 Gateway、业务 API、Worker、seed 和 smoke 工具永远不接触百炼 Key;所有百炼请求只由独立 `model-gateway` 发起。 4. 日志不得记录 `Authorization` 请求头或完整模型请求体。 5. 工作空间真实域名也只进入本地部署配置;文档统一使用 ``。 6. 每次提交前执行 `make verify`;仓库已配置本地钩子和 Gitea Actions 双重检查,Actions 需以远端 runner 首次成功记录作为生效证据。 @@ -33,11 +33,15 @@ ```text React/Nginx + -> 无 Secret 入口 Gateway -> FastAPI API -> PostgreSQL + pgvector - -> 阿里云百炼 Embedding / Rerank / Chat - -> FastAPI Worker(与 API 共用同一 Python 代码镜像) + -> 内部 model-gateway client + -> Python Worker(与 API 共用同一 Python 代码镜像) -> 文档解析、分块、向量化和持久化任务 + -> 内部 model-gateway client +model-gateway(唯一持有百炼 Key 和公网出口) + -> 阿里云百炼 Embedding / Rerank / Chat ``` 默认模型链: @@ -51,7 +55,7 @@ deepseek-v4-flash -> 只依据证据回答并给出页码引用 ``` -选择 PostgreSQL + pgvector 的原因是当前预计 1 万至 30 万切片,单机 PostgreSQL 已足够,并能把业务状态、元数据、向量和后台任务放在一个事务边界中。这样核心长期运行组件只有 Web、API、Worker、数据库,一条 `docker compose up -d --build` 即可启动。达到百万级切片或高吞吐边界后再评估 Qdrant,而不是在第一版提前承担双写一致性和第二套备份系统。 +选择 PostgreSQL + pgvector 的原因是当前预计 1 万至 30 万切片,单机 PostgreSQL 已足够,并能把业务状态、元数据、向量和后台任务放在一个事务边界中。为把数据库权限与云凭证/公网出口分开,运行拓扑额外保留一个很小的 `model-gateway` 安全边界;它复用后端镜像和领域端口,不把业务拆成多套数据库或分布式事务。达到百万级切片或高吞吐边界后再评估 Qdrant,而不是在第一版提前承担双写一致性和第二套备份系统。 ## 2. 项目目标和边界 @@ -136,13 +140,14 @@ flowchart LR G -->|"internal data"| A["FastAPI API"] A --> D[("PostgreSQL + pgvector")] A --> F[("文件卷 / OSS 适配器")] - A --> E["text-embedding-v4"] - A --> R["qwen3-rerank"] - A --> L["deepseek-v4-flash"] + A -->|"Bearer + X-RAG-Caller: api"| M["内部 model-gateway"] + M --> E["text-embedding-v4"] + M --> R["qwen3-rerank"] + M --> L["deepseek-v4-flash"] J["DB 持久化任务"] --> K["Python Worker"] K --> D K --> F - K --> E + K -->|"Bearer + X-RAG-Caller: worker"| M A --> J ``` @@ -154,13 +159,14 @@ flowchart LR | `gateway` | 固定 API 上游、请求大小/头边界、脱敏错误和流式转发 | 数据库凭证、模型凭证、业务权限判断 | | `api` | 认证、知识库、检索、问答、评测 API | 长时间 OCR/批量入库 | | `worker` | 解析、OCR、分块、向量化、索引、评测批任务 | 对外开放端口 | +| `model-gateway` | 唯一读取百炼 Key、统一模型协议/限流/脱敏错误;校验 API/Worker 内部身份 | 数据库、上传卷、外部端口、业务授权 | | `db` | 元数据、向量、任务、会话、评测事实来源 | 原始大文件长期存储 | | `storage` | 开发期 Docker volume,生产可切换 OSS | 访问控制的最终判定 | -| 百炼适配器 | 统一超时、重试、计量、错误映射 | 记录密钥或正文日志 | +| 百炼适配器 | 仅在 `model-gateway` 进程内统一超时、重试、计量、错误映射 | 记录密钥或正文日志 | ### 4.3 为什么不先拆微服务 -解析、检索、生成和评测共享同一领域模型、配置和数据库。拆分微服务会提前引入服务发现、分布式追踪、数据一致性和接口版本问题,却没有对应吞吐收益。第一版使用一个 Python 包,由 `api` 和 `worker` 两个进程加载;当某个模块具有独立扩容证据时再拆分。 +解析、检索、生成和评测共享同一领域模型、配置和数据库。拆分业务微服务会提前引入服务发现、分布式追踪、数据一致性和接口版本问题,却没有对应吞吐收益。第一版使用一个 Python 包,由 `api`、`worker` 和 `model-gateway` 三种进程加载;其中 `model-gateway` 不是独立业务服务,而是最小权限的凭证/出口隔离边界。详细决策见 [ADR-0005](adr/0005-isolate-model-egress.md)。 ## 5. 技术选型 @@ -209,7 +215,7 @@ ADR 见 [ADR-0001](adr/0001-use-pgvector.md)。 ### 6.1 端点必须分开 -业务空间专属域名的 Key、地域和 Base URL 必须匹配;官方推荐生产使用专属域名,见[Base URL 总览](https://help.aliyun.com/zh/model-studio/base-url)。配置只保存以下形态: +业务空间专属域名的 Key、地域和 Base URL 必须匹配;官方推荐生产使用专属域名,见[Base URL 总览](https://help.aliyun.com/zh/model-studio/base-url)。这些配置和 Key 只注入 `model-gateway`: ```dotenv BAILIAN_OPENAI_BASE_URL=https://.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 @@ -227,6 +233,17 @@ DASHSCOPE_API_KEY_FILE=/run/secrets/bailian_api_key `/apps/anthropic` 是 Anthropic 协议入口,本项目不使用。尤其不能把 `/reranks` 拼到 `/compatible-mode/v1` 后面;官方的 Rerank 路径是独立的 `/compatible-api/v1/reranks`。 +业务进程不接受任意模型名、供应商 URL 或向量维度,而只调用固定内部接口: + +```text +POST /internal/v1/embeddings +POST /internal/v1/rerank +POST /internal/v1/chat/completions +POST /internal/v1/chat/stream +``` + +调用必须同时携带 `Authorization: Bearer ` 和 `X-RAG-Caller: api|worker`。服务端以常量时间比较 token 并要求 token 身份与 caller 一致;`api` 只允许查询向量、重排和聊天,文档向量化只允许 `worker` 身份。两个内部 token 相互独立、只通过 Docker Secret 注入。API、seed 和 smoke 工具均经内部客户端访问,既不挂载百炼 Key,也不加入公网 `egress` 网络。 + ### 6.2 Embedding 契约 基线请求: @@ -365,6 +382,10 @@ UPLOADED | `evaluation_sets/cases` | 版本化问题、证据和标签 | | `evaluation_runs/results` | 参数快照、指标、延迟、成本和错误 | | `audit_logs` | 管理操作和安全审计 | +| `model_profiles` | 模型种类、模型名、API 模式、1024 维约束、端点身份哈希和无凭证配置快照;受控 cache epoch 必须进入不可变 profile hash | +| `embedding_cache` | 以 `profile_hash + embedding_text_sha256` 唯一缓存 1024 维向量和脱敏调用元数据 | +| `chunk_embedding_assignments` | chunk/profile 到缓存项的状态化绑定,保证 READY 只指向同文本同 profile 向量 | +| `model_invocations` | 只记录 trace、caller、模型、用量、耗时、request ID 和脱敏错误,不保存请求/响应正文 | ### 8.2 核心向量表(示意) @@ -441,6 +462,8 @@ COMMIT; - 删除先清空 `active_version_id` 并令投影不可检索,再异步物理删除文件和向量; - 任何回答保留当时的 chunk ID、模型和 Prompt 版本,保证复现。 +Alembic `0002_model_profiles` 已实现上述 profile、缓存、assignment 和调用审计表,为 `knowledge_bases` 增加激活 Embedding profile,并为 `chunks` 增加稳定唯一的 `citation_id`。迁移只会为可明确识别的单一 synthetic fake profile 做安全回填,绝不从模型别名或 URL 猜测真实供应商身份;同一知识库存在多个候选 profile 时保持未激活,等待显式治理。已在独立全新 volume 验证 `空库 -> 0001 -> 0002 -> 0001 -> 0002`,并在已有 20 条合成向量的数据卷完成相同升降级;重复 seed 后 profile/active KB 为 1/1,cache/READY assignment/citation 均为 20,且 citation 无重复。 + ## 9. 在线 RAG 流程 ### 9.1 主流程 @@ -527,6 +550,8 @@ Rerank 只能重新排序已召回候选,无法找回初召回遗漏的证据 ## 10. 后端 API 设计 +FastAPI 入口通过 `create_app()` 应用工厂构建,导入阶段不打开数据库、不读取百炼凭证。HTTP 请求由统一中间件生成或透传安全的 trace ID;业务错误使用稳定的 Problem JSON 响应并回传 trace,未知异常不得泄漏连接串、Secret 路径或供应商正文。`/health/live` 只证明进程存活,`/health/ready` 只验证本地数据库依赖;百炼能力状态由独立模型探测展示,避免供应商短时故障触发 API 重启风暴。 + 统一前缀 `/api/v1`: ```text @@ -703,6 +728,10 @@ pgvector 官方给出的单精度 `vector` 存储约为 `4 * dimensions + 8` 字 详细指标与阶段完成定义分别见 [01-data-and-evaluation.md](01-data-and-evaluation.md) 和 [03-implementation-plan.md](03-implementation-plan.md)。 +### 15.1 2026-07-13 实现边界 + +当前已实现安全运行骨架、内部 `model-gateway` 及 token 身份、模型内部客户端、应用工厂/Problem/trace 契约和 `0002_model_profiles` 迁移代码;`provider-smoke`、真实 `seed-demo` 和 API 进程不再直接持有百炼 Key。当前工作空间对 `text-embedding-v4`、`qwen3-rerank`、`deepseek-v4-flash` 的实际请求仍返回 401,因此三能力真实可用性尚未验收。数字文档上传/解析/审核、正式检索 API、grounded chat、Worker 租约恢复、评测运行器和完整产品 UI 仍是后续实现项;不得把本节理解为“整个项目完成”。 + ## 16. 参考资料 1. [阿里云百炼 Base URL 总览](https://help.aliyun.com/zh/model-studio/base-url) diff --git a/docs/01-data-and-evaluation.md b/docs/01-data-and-evaluation.md index dd3f0e6..afd80f1 100644 --- a/docs/01-data-and-evaluation.md +++ b/docs/01-data-and-evaluation.md @@ -443,7 +443,7 @@ embedding_cache_key = SHA256( chunk_embedding_assignment = UNIQUE(chunk_id, embedding_profile_hash) ``` -相同文本可以安全复用向量缓存,但每个 chunk 都必须建立独立 assignment,不能用缓存键替代任务/行唯一键。模型别名无法解析到稳定 revision 时,每次冻结实验显式设置 `cache_epoch`,避免别名漂移后复用旧向量。激活前再次核对当前 `embedding_text_sha256`、完整 profile 与 assignment;任何一项变化都生成新索引版本。 +相同文本可以安全复用向量缓存,但每个 chunk 都必须建立独立 assignment,不能用缓存键替代任务/行唯一键。模型别名无法解析到稳定 revision 时,每次冻结实验显式设置 `cache_epoch`,并把它纳入不可变的 `embedding_profile_hash`;epoch 变化必须创建新 profile,禁止原地修改 profile 后继续复用旧缓存。激活前再次核对当前 `embedding_text_sha256`、完整 profile 与 assignment;任何一项变化都生成新索引版本。 `endpoint_identity_hash` 由规范化后的工作空间/部署端点身份计算,仅保存哈希而不在实验导出中暴露真实工作空间。这样同地域、同模型别名但不同 MaaS 工作空间的向量不会错误共用缓存。 diff --git a/docs/02-deployment-and-security.md b/docs/02-deployment-and-security.md index 3878f97..ac8df54 100644 --- a/docs/02-deployment-and-security.md +++ b/docs/02-deployment-and-security.md @@ -3,7 +3,7 @@ | 项目 | 设计值 | |---|---| | 部署方式 | Docker Compose 单机 | -| 长期服务 | `web`、`api`、`worker`、`db` | +| 长期服务 | 当前 `web`、`gateway`、`api`、`model-gateway`、`db`;后续加入 `worker` | | 一次性服务 | `migrate` | | 对外端口 | 仅 Nginx/Web | | 密钥 | Docker Secret / Secret Manager | @@ -17,7 +17,7 @@ docker compose up -d --build ``` -这条命令应完成数据库健康等待、迁移、Web/API/Worker 启动。它不等于“所有数据和模型凭证自动生成”,首次部署仍必须完成密钥轮换、Secret 创建和三模型能力探测。 +这条命令当前完成数据库健康等待、一次性迁移、`model-gateway`、API、入口 Gateway 和 Web 启动;Worker 落地后也遵循同一依赖链。它不等于“所有数据和模型凭证自动生成”,首次部署仍必须完成密钥轮换、Secret 创建和三模型能力探测。 架构定位是适合毕设、演示和单机内部使用的生产式单节点,不宣称跨主机高可用。互联网生产环境应替换托管数据库、对象存储和集中密钥管理。 @@ -29,8 +29,9 @@ docker compose up -d --build |---|---|---|---| | `db` | 固定版本 pgvector 镜像 | 元数据、向量、任务、会话、评测 | 仅内部网络 | | `migrate` | 与后端同镜像 | 运行 Alembic,一次成功退出 | 无 | -| `api` | `backend/Dockerfile` | FastAPI、检索、问答、管理 API | 仅 internal data 网络 | +| `api` | `backend/Dockerfile` | FastAPI、检索、问答、管理 API | 仅 internal data/model 网络 | | `gateway` | 与后端同镜像 | 固定上游、请求边界、脱敏错误和流式转发 | 仅 internal ingress/data 网络 | +| `model-gateway` | 与后端同镜像 | 唯一读取百炼 Key,代理 Embedding/Rerank/Chat | 仅 internal model + egress 网络,无宿主机端口 | | `worker` | 同一后端镜像 | 解析、向量化、评测后台任务 | 无 | | `web` | `frontend/Dockerfile` | React 静态资源、Nginx 同源入口和 SSE 代理 | `127.0.0.1:8000`;生产部署 HTTPS | | `ocr-worker` | 可选 profile | PaddleOCR 重型任务 | 无 | @@ -72,6 +73,11 @@ x-rag-config: &rag-config MODEL_MAX_RETRIES: "${MODEL_MAX_RETRIES:-3}" MODEL_MAX_CONCURRENCY: "${MODEL_MAX_CONCURRENCY:-4}" +x-model-client-config: &model-client-config + MODEL_GATEWAY_BASE_URL: http://model-gateway:8000 + MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_api_token + MODEL_GATEWAY_CALLER: api + services: db: image: pgvector/pgvector:0.8.2-pg17 @@ -115,15 +121,35 @@ services: networks: [data] restart: "no" + model-gateway: + build: ./backend + command: ["uvicorn", "app.model_gateway:app", "--host", "0.0.0.0", "--port", "8000"] + environment: + <<: *rag-config + MODEL_GATEWAY_ALLOWED_TOKEN_FILES: >- + api=/run/secrets/model_gateway_api_token,worker=/run/secrets/model_gateway_worker_token + secrets: + - bailian_api_key + - model_gateway_api_token + - model_gateway_worker_token + networks: [model, egress] + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=2)"] + restart: unless-stopped + api: build: ./backend command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] depends_on: migrate: condition: service_completed_successfully - environment: *runtime-config + model-gateway: + condition: service_healthy + environment: + <<: [*runtime-config, *model-client-config] secrets: - postgres_app_password + - model_gateway_api_token volumes: - uploads:/data/uploads healthcheck: @@ -132,7 +158,7 @@ services: timeout: 3s retries: 12 start_period: 10s - networks: [data] + networks: [data, model] restart: unless-stopped gateway: @@ -151,24 +177,29 @@ services: migrate: condition: service_completed_successfully environment: - <<: [*runtime-config, *rag-config] + <<: [*runtime-config, *model-client-config] + MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_worker_token + MODEL_GATEWAY_CALLER: worker WORKER_CAPABILITIES: ${WORKER_CAPABILITIES:-document_parse,embedding,rerank,evaluation} secrets: - postgres_app_password - - bailian_api_key + - model_gateway_worker_token volumes: - uploads:/data/uploads - networks: [data, egress] + networks: [data, model] restart: unless-stopped provider-smoke: build: ./backend command: ["python", "-m", "app.tools.provider_smoke"] profiles: ["tools"] - environment: *rag-config + depends_on: + model-gateway: + condition: service_healthy + environment: *model-client-config secrets: - - bailian_api_key - networks: [egress] + - model_gateway_api_token + networks: [model] restart: "no" seed-demo: @@ -179,11 +210,13 @@ services: migrate: condition: service_completed_successfully environment: - <<: [*runtime-config, *rag-config] + <<: [*runtime-config, *model-client-config] + MODEL_GATEWAY_TOKEN_FILE: /run/secrets/model_gateway_worker_token + MODEL_GATEWAY_CALLER: worker secrets: - postgres_app_password - - bailian_api_key - networks: [data, egress] + - model_gateway_worker_token + networks: [data, model] restart: "no" web: @@ -209,6 +242,9 @@ networks: data: driver: bridge internal: true + model: + driver: bridge + internal: true egress: driver: bridge @@ -219,13 +255,17 @@ secrets: file: ./secrets/postgres_migrator_password postgres_app_password: file: ./secrets/postgres_app_password + model_gateway_api_token: + file: ./secrets/model_gateway_api_token + model_gateway_worker_token: + file: ./secrets/model_gateway_worker_token bailian_api_key: file: ./secrets/bailian_api_key ``` -最终实现固定镜像 digest,示意版本只说明可行结构。`migrate` 只有一个实例执行;API 和 Worker 不在启动时并发自动迁移。首次初始化脚本只在 `db` 容器内使用 bootstrap 凭据创建 `vector` 扩展、无超级用户权限的 migrator/app 登录角色和专属 schema;migrator 拥有 schema/DDL,app 仅获运行期所需的 DML/sequence 权限及默认权限,API/Worker 永远不挂载 bootstrap 或 migrator Secret。最后一个初始化脚本只有在全部 SQL 事务和授权成功后,才在 `PGDATA` 内用同文件系统 rename 原子写入 `.rag-bootstrap-complete`;healthcheck 同时检查 `pg_isready` 与该哨兵,防止临时初始化 server 提前放行迁移。备份再使用独立只读角色。Compose 的 `.env` 仅做变量插值,不会自动注入容器,因此可配置的非敏感项和 `*_FILE` 路径通过 YAML anchor 在 `environment` 中完整声明。后端从各自的 `POSTGRES_PASSWORD_FILE` 读取密码后在内存中组装 DSN,不把明文密码放进 `.env` 或 `DATABASE_URL`。 +实际实现固定数据库镜像 digest。`migrate` 只有一个实例执行;成功应用到 head 后退出码为 0,随后在 `docker compose ps -a` 显示 `Exited (0)` 是一次性任务的正常终态,不是服务暂停。API 和 Worker 不在启动时并发自动迁移。首次初始化脚本只在 `db` 容器内使用 bootstrap 凭据创建 `vector` 扩展、无超级用户权限的 migrator/app 登录角色和专属 schema;migrator 拥有 schema/DDL,app 仅获运行期所需的 DML/sequence 权限及默认权限,API/Worker 永远不挂载 bootstrap 或 migrator Secret。最后一个初始化脚本只有在全部 SQL 事务和授权成功后,才在 `PGDATA` 内用同文件系统 rename 原子写入 `.rag-bootstrap-complete`;healthcheck 同时检查 `pg_isready` 与该哨兵,防止临时初始化 server 提前放行迁移。Compose 的 `.env` 仅做变量插值,不会自动注入容器;后端从 `*_FILE` 读取 Secret,不把明文密码放进 `.env` 或 `DATABASE_URL`。 -网络分为四层:`web` 位于普通 `edge` 和 internal `ingress`;`gateway` 位于 `ingress + data`;API、迁移、Worker 和数据库位于 internal `data`;只有明确需要云调用的一次性工具或未来 Worker 才能加入命名的 `egress`。因此浏览器入口不能横向连接数据库,数据库感知 API 也没有公网默认出口。只有无 Secret 的 Web 容器发布回环端口;gateway 固定上游并继续执行请求边界和脱敏错误契约。详细理由和被否决方案见 [ADR-0004](adr/0004-secretless-web-ingress.md)。必须注意:普通 `edge` bridge 为 Web 提供宿主机端口发布时,也给 Web 留有技术上的默认公网出口;当前接受这一点的前提是 Web 无 Secret、无数据网络、无挂载且代理上游固定。若部署基线要求入口容器也完全禁网,必须在宿主机防火墙/出口代理层阻断,不能把“未加入名为 egress 的网络”误写成“没有外网出口”。普通 bridge 本身也不构成域名白名单,正式联网 Worker 仍需主机防火墙或出口代理只放行百炼和对象存储域名。 +网络分为五层:`web` 位于普通 `edge` 和 internal `ingress`;入口 `gateway` 位于 `ingress + data`;API 位于 internal `data + model`;数据库/迁移位于 internal `data`;只有 `model-gateway` 位于 internal `model + egress`。`provider-smoke` 只持 API 内部 token,真实 `seed-demo` 只持数据库 Secret 和 Worker 内部 token;二者都不挂载百炼 Key、不加入 `egress`。未来 Worker 沿用相同边界。`model-gateway` 不连接数据库、不挂载上传卷、不发布端口。入口隔离见 [ADR-0004](adr/0004-secretless-web-ingress.md),模型出口隔离见 [ADR-0005](adr/0005-isolate-model-egress.md)。必须注意:普通 `edge` bridge 为 Web 提供宿主机端口发布时,也给 Web 留有技术上的默认公网出口;生产仍需主机防火墙或出口代理只允许 `model-gateway` 到百炼域名。 ### 2.3 后台任务为何不用 Redis @@ -289,7 +329,9 @@ def read_secret(file_env: str, value_env: str) -> str: return os.environ[value_env] ``` -生产只允许文件/Secret Manager;本地值环境变量仅作为临时兼容。应用启动后不打印 Settings 的 Secret 字段,异常对象不得包含请求头。 +生产只允许文件/Secret Manager;本地值环境变量仅作为临时兼容。`bailian_api_key` 只挂载到 `model-gateway`。API 与 Worker 分别挂载 `model_gateway_api_token`、`model_gateway_worker_token`;调用同时使用 Bearer token 和 `X-RAG-Caller`,服务端以常量时间比较并拒绝 token/身份不匹配。应用启动后不打印 Settings 的 Secret 字段,异常对象不得包含请求头。 + +Secret 或模型端点配置在运行中发生变化时,`model-gateway` 会原子清空旧 provider 与内部 token,并进入 `restart_required`;旧、新 token 都不能在旧进程继续调用。Docker 的 `restart: unless-stopped` 不会仅因 unhealthy 自动重启,因此轮换操作必须协调执行 `docker compose restart model-gateway api`(Worker 落地后同时重启 Worker),待 readiness 恢复后再运行 provider smoke。禁止只替换文件而让旧进程长期携带旧凭据。 ### 3.3 Git 防泄漏 @@ -326,6 +368,10 @@ EMBEDDING_MODEL=text-embedding-v4 EMBEDDING_DIMENSION=1024 RERANK_MODEL=qwen3-rerank LLM_MODEL=deepseek-v4-flash + +MODEL_GATEWAY_BASE_URL=http://model-gateway:8000 +MODEL_GATEWAY_TOKEN_FILE=/run/secrets/model_gateway_api_token +MODEL_GATEWAY_CALLER=api ``` 启动时 fail fast: @@ -336,6 +382,8 @@ LLM_MODEL=deepseek-v4-flash - Embedding 维度为 schema 支持的 1024; - 生产没有默认密码和通配 CORS; - Secret 文件存在、非空且权限合理; +- 内部模型地址必须精确为 `http://model-gateway:8000`,不能通过配置改成公网或用户控制的 URL; +- API/Worker 内部 token 必须不同,caller 必须与 token 身份一致; - `APP_ENV=production` 时关闭开发文档或增加管理员保护。 ### 4.2 地域与计费方案 @@ -373,13 +421,15 @@ LLM_MODEL=deepseek-v4-flash `GET {OPENAI_BASE}/models` 可作为辅助清单,但不能证明 Rerank 路径和三个模型权限都正常,最小实际调用才是最终验证。 +截至 2026-07-13,最近一次真实探测已到达百炼端点,但 Embedding、Rerank、Chat 三项均返回 401。改由内部 `model-gateway` 路由后必须重新运行同一探测;已有 401 证明不了模型授权可用。必须核对 Key 所属工作空间、北京地域、端点和计费/模型权限后重新验收。401 不进入自动重试风暴,也不能把 Stage 1 标记完成。 + ## 6. 网络与接口安全 ### 6.1 网络边界 - 只发布 Nginx 端口;数据库、API、Worker 不直接暴露公网; - Nginx 到 API 使用内部网络; -- `data` 网络启用 Docker internal 隔离,Web 不加入;API/Worker 通过独立 `egress` 网络访问云服务; +- `data` 和 `model` 网络启用 Docker internal 隔离,Web 不加入;API/Worker 只经 `model-gateway` 访问云服务,自身不加入 `egress`; - 生产入口启用 TLS、HSTS 和安全响应头; - 出站只允许百炼/OSS 等必要域名; - 数据库账号按迁移、应用、备份职责分权; @@ -442,11 +492,12 @@ LLM_MODEL=deepseek-v4-flash ### 8.1 健康端点 - `/health/live`:进程事件循环可用,不检查外部模型; -- `/health/ready`:数据库、迁移版本、存储路径和配置可用; +- API `/health/ready`:当前验证数据库可用;后续再纳入迁移版本和存储路径对账; +- `model-gateway /health/ready`:只验证本地 Key/token/端点配置可加载且未漂移,不产生计费云调用; - `/admin/providers/health`:按需调用三个模型,结果短期缓存; - `/admin/index/health`:`documents.active_version_id`、searchable 切片投影、非空向量、文本哈希和 embedding profile 一致性。 -百炼短时波动不应让编排器不断重启健康 API 容器。 +百炼短时波动不应让编排器不断重启健康 API 容器。真实三能力是否可用只能由显式 provider smoke 证明,不能从 `model-gateway` readiness 推断。 ### 8.2 日志脱敏 diff --git a/docs/04-project-todo.md b/docs/04-project-todo.md index 9b54358..ac762ae 100644 --- a/docs/04-project-todo.md +++ b/docs/04-project-todo.md @@ -2,12 +2,12 @@ | 项目 | 当前值 | |---|---| -| 基线日期 | 2026-07-12 | +| 基线日期 | 2026-07-13 | | 当前阶段 | Stage 1:模型与数据库 PoC,`IN_PROGRESS` | | 已完成阶段 | Stage 0:仓库、安全和设计基线 | | 整体完成度 | 约 12%,合理区间 10%–15% | | 设计完成度 | 设计基线已完成;实现中发现新约束时继续通过 ADR 和文档修订维护 | -| 业务代码完成度 | 约 18%;Stage 1 的 Docker API、离线检索、数据库 PoC,以及 React 离线演示、Nginx 单入口和四网络隔离前置均已验收;真实百炼 smoke 仍待轮换后的新 Key | +| 业务代码完成度 | 不以新增文件提前抬高里程碑;已增加独立 `model-gateway`、内部 token 身份、应用工厂/Problem/trace 和 `0002` profile/cache 迁移,完整产品主链路仍未完成 | | 当前预计剩余工期 | 约 9–13 周,含 300 题正式标注、盲测、论文和答辩缓冲 | | 进度权威来源 | 本文的阶段状态、验收证据和已推送提交 | @@ -50,7 +50,7 @@ | Stage 9 答辩、发布与归档 | 3% | `TODO` | 0% | | **合计** | **100%** | — | **12%** | -Stage 1 已完成可离线验收的数据库、适配器契约、synthetic seed、只读 API 和 Web 演示子闭环,安全离线子阶段内部约完成 95%;但真实三模型 smoke 仍依赖轮换后的新 Key,因此 Stage 1 不提前计入整体里程碑。Stage 2 中不依赖云密钥的 React、Nginx、网络隔离和质量门禁前置已经完成,但 Worker 租约、完整产品工作流等必选项尚未完成,Stage 2 仍为 `TODO`。本文复选框表示“已通过验收”,不表示“文件是否已经出现”。当前可诚实表述为:**设计基线完成,整体里程碑约 12%,业务代码约 18%,Stage 1 安全离线链路可运行,真实百炼验证尚未完成。** +Stage 1 已完成可离线验收的数据库、适配器契约、synthetic seed、只读 API 和 Web 演示子闭环,并已把百炼 Key/公网出口收敛到独立 `model-gateway`;但真实三模型请求当前均返回 401,因此 Stage 1 不提前计入整体里程碑。Stage 2 中应用工厂、Problem/trace、React、Nginx、网络隔离和质量门禁已作为前置落地,`0002_model_profiles` 迁移代码也已建立;Worker 租约、上传入库、正式检索/聊天和评测等必选项尚未完成,Stage 2 仍为 `TODO`。本文复选框表示“已通过对应层级的验收”,不表示“文件是否已经出现”。当前可诚实表述为:**设计基线完成,整体里程碑仍约 12%,安全离线链路可运行,真实百炼验证和产品主链路尚未完成。** ## 2. 阶段依赖与关键路径 @@ -99,8 +99,8 @@ Stage 0 DONE - `git status --short --branch` 显示 `main...origin/main`,Stage 0 基线提交远端可见。 - `make verify-design` 可检查 Secret、设计文档、相对链接和 diff。 -- [00-overall-design.md](00-overall-design.md) 明确状态为“设计基线,待实现验证”。 -- 当前不存在可运行应用,因此 Stage 0 的完成只代表设计和工程防线完成。 +- [00-overall-design.md](00-overall-design.md) 现已同步“设计基线;安全运行骨架部分实现,产品主链路未完成”。 +- Stage 0 完成时尚无可运行应用;后续运行骨架不改变“Stage 0 只代表设计和工程防线”的历史验收口径。 ### 已完成提交/推送节点 @@ -133,11 +133,13 @@ Stage 0 DONE - [x] 创建 `backend/pyproject.toml` 和依赖锁文件,不引入未说明依赖。 - [x] 创建最小 `backend/Dockerfile`,使用非 root 用户和固定基础镜像 digest。 -- [x] 创建最小 `compose.yaml`,包含 `db + migrate + provider-smoke + seed-demo/offline`。 +- [x] 创建 Compose,包含 `db + migrate + model-gateway + api + gateway + web + provider-smoke + seed-demo/offline`;Worker 仍待实现。 - [x] 启动最小 FastAPI `api + gateway`,内部 API 无 egress,gateway 无 Secret 并仅发布回环端口;提供真实数据库 readiness、Swagger 和只读 synthetic demo 检索。 - [x] 创建 PostgreSQL bootstrap、migrator 和 app 分权角色初始化脚本;备份只读角色在备份功能落地时单独创建。 - [x] 创建 Alembic 基线迁移,启用 pgvector 并建立 1024 维向量表/HNSW 基线。 - [x] 确保运行期服务不挂载 bootstrap 或 migrator Secret。 +- [x] 将百炼 Key 和公网出口隔离到独立 `model-gateway`;API/seed/smoke 只持对应内部 token,不直持 Key。 +- [x] 以 `Authorization: Bearer` + `X-RAG-Caller: api|worker` 建立内部身份,常量时间比较并限制 API 身份不得执行文档向量化。 #### 4.3 三模型适配器 @@ -157,6 +159,7 @@ Stage 0 DONE - [x] 建立并执行迁移空 volume、角色权限和 Docker 重启持久化测试。 - [x] 明确文档族 split、盲测保管人和“盲测用于调参即退役”规则。 - [x] 扩展 `make verify`,纳入 Stage 1 后端格式、类型和测试门禁。 +- [x] 实现并实跑 `0002_model_profiles`:profile、embedding cache、chunk assignment、model invocation 与稳定 `citation_id`;空卷和已有数据均完成 upgrade/downgrade/upgrade。 ### 验收证据 @@ -165,12 +168,13 @@ Stage 0 DONE - [x] `docker compose run --rm seed-demo-offline` 完成 20 条虚构数据写入、检索和重排;真实模式待新 Key。 - [x] `api + gateway` 镜像构建并达到 healthy;live/ready/meta/demo status/demo search 均通过真实 HTTP 验收。 - [x] 第二次运行 seed 后数据库计数与第一次相同,均为 chunks/vectors/searchable = 20/20/20。 -- [x] 65 项后端测试证明模型契约、健康检查、固定上游 gateway、SSE 代理分块与早断连释放契约、离线 demo、数据治理和失败路径正确。 +- [x] 已验证测试集证明模型契约、健康检查、固定上游 gateway、SSE 代理分块与早断连释放契约、离线 demo、数据治理和失败路径正确;本轮新增 model-gateway/0002 契约须随阶段提交重新运行全量门禁。 - [x] `make verify`、Secret 扫描、固定镜像构建和 `git diff --check` 通过。 ### 2026-07-12 已验证运行证据 - 全新 volume 上 `db` 达到 healthy,`migrate` 以非超级用户退出码 0,版本为 `0001_initial_schema`。 +- `0002_model_profiles` 已在独立全新 volume 完成 `空库 -> 0001 -> 0002 -> 0001 -> 0002`,并在已有 20 条合成数据的 volume 完成 downgrade/upgrade;重复 seed 后 profile/active KB=1/1、cache/READY assignment/citation=20/20/20,citation 无重复。 - `vector` 扩展与 HNSW 基线存在;app/migrator 均非超级用户,app 无 `rag` schema DDL 权限但具有所需 DML。 - 两次离线 seed 均输出 20/20/20,9 个可回答虚构问题 Hit@3 = 9/9。 - FastAPI 容器使用 app 最小权限角色,根文件系统只读且无 egress;无 Secret gateway 仅提供回环入口;Swagger 可见,demo search 返回合成片段与不透明 citation ID。 @@ -179,9 +183,9 @@ Stage 0 DONE - app 尝试修改已 `CLOUD_APPROVED` 的 `cloud_text` 被审批不可变触发器拒绝;尝试建表被权限拒绝。 - React + TypeScript strict 离线演示已实现工作台与系统状态页;14 项前端测试、ESLint、类型检查和生产构建通过。 - Nginx 成为唯一回环入口,Compose 仅发布 `127.0.0.1:8000`;`web -> gateway -> api` 同源链路、Swagger、OpenAPI、健康检查和 synthetic search 均通过真实 HTTP 验收。 -- `edge / ingress / data / egress` 四网络边界已落地;Web 不可解析数据库,API 与 gateway 无公网 TCP 出口,Web 与 gateway 均不持有 Secret。 +- `edge / ingress / data / model / egress` 五网络边界已落地;API、seed、smoke 不挂载百炼 Key,只有不连数据库/上传卷的 `model-gateway` 同时连接 internal model 与 egress。 - gateway 与 Nginx 已关闭代理缓冲,自动测试证明两个 SSE 事件保持为独立下游分块,且响应头阶段断连也会释放上游流;真实聊天生成与取消传播仍属于 Stage 5,尚未实现。 -- 唯一未验收项是真实 `text-embedding-v4` / `qwen3-rerank` / `deepseek-v4-flash` smoke 与真实模式 seed。 +- 当前真实 `text-embedding-v4` / `qwen3-rerank` / `deepseek-v4-flash` 请求均到达供应商但返回 401;三能力与真实模式 seed 未验收。上传、正式检索、grounded chat、Worker 和评测仍属于后续阶段,不能称为“唯一剩余项”。 ### 提交/推送节点 @@ -206,16 +210,16 @@ Stage 0 DONE - **状态:** `TODO` - **预计工期:** 3–4 个工作日 - **依赖:** Stage 1 `DONE` -- **已完成前置:** 不依赖云密钥的 React 离线演示、OpenAPI 类型、Nginx 单入口、四网络隔离和当前质量门禁已验收;这不代表 Stage 2 已启动或完成。 +- **已完成前置:** 不依赖云密钥的 React 离线演示、OpenAPI 类型、Nginx 单入口和质量门禁已验收;模型隔离网络加入后当前为五层网络。这不代表 Stage 2 已启动或完成。 ### 具体实现任务 -- [ ] 建立 FastAPI 应用工厂、配置加载、稳定错误码和 OpenAPI。 +- [x] 建立 FastAPI 应用工厂、配置加载、稳定 Problem JSON、trace middleware 和 OpenAPI;导入阶段不连库、不读模型凭证。 - [ ] 按 `domain -> ports -> services -> adapters/persistence` 落实后端依赖边界。 -- [ ] 建立 SQLAlchemy 仓储、Alembic 迁移运行器和事务边界。 +- [ ] 建立完整 SQLAlchemy 仓储和业务事务边界;Alembic 已推进到 `0002_model_profiles`,且空卷/已有数据升降级已验证。 - [ ] 建立 PostgreSQL `background_jobs`、租约、lease token、fencing 和重试状态机。 - [ ] 建立 Worker maintenance loop、advisory-lock reaper 和强杀恢复测试。 -- [ ] 建立 `/health/live`、`/health/ready` 和管理员模型能力探测端点。 +- [ ] `/health/live`、数据库 `/health/ready` 及 model-gateway 本地 readiness 已有;管理员模型能力探测 API 仍待实现。 - [x] 建立 React + TypeScript strict、路由、TanStack Query 和 OpenAPI 客户端生成。 - [x] 建立 Nginx,仅发布 Web 端口;当前 API、DB 只在内部网络,Worker 落地后必须沿用同一边界。 - [ ] 建立完整 Compose:`web/api/worker/db/migrate`,OCR profile 默认关闭。 @@ -229,11 +233,11 @@ Stage 0 DONE - [x] 当前 `docker compose ps` 只暴露 Nginx/Web 回环端口;加入 Worker 后须重新验收。 - [ ] Docker 重启后数据库数据保留。 - [ ] 强杀 Worker 后验证可重领、耗尽进入 `FAILED`、旧 token 不能提交。 -- [x] 当前后端 65 项、前端 14 项测试及两端 lint、类型和构建门禁均通过;Stage 2 新功能仍需增量验证。 +- [x] 已提交基线的两端 lint、类型、测试和构建门禁通过;本轮新增 model-gateway、应用工厂和 `0002` 仍须在提交前以 `make verify` 重新确认。 ### 提交/推送节点 -- [x] `S2-PREP`:React 离线演示、Nginx 单入口、四网络隔离、SSE 代理安全修复与质量门禁已随 `c3bad0f` 验证并推送;该前置提交不代表 Stage 2 已启动或完成。 +- [x] `S2-PREP`:React 离线演示、Nginx 单入口、当时的四网络隔离、SSE 代理安全修复与质量门禁已随 `c3bad0f` 验证并推送;后续 `model` 网络把当前拓扑扩为五层,该前置提交不代表 Stage 2 已启动或完成。 - [ ] `S2-A`:FastAPI、持久化、健康端点和错误契约。 - [ ] `S2-B`:任务租约、reaper、fencing 和恢复测试。 - [ ] `S2-C`:React、Nginx、完整 Compose、CI 和文档;其中 React/Nginx/CI 前置已完成,缺 Worker 与完整 Compose 验收,节点不得提前关闭。 @@ -561,12 +565,12 @@ Stage 0 DONE ## 15. 下一步执行顺序 -当前主线仍是关闭 Stage 1 的真实供应商门禁;已完成的 React/Nginx 工作仅作为 Stage 2 安全前置保留。建议按以下顺序继续: +当前主线仍是关闭 Stage 1 的真实供应商门禁,同时继续不依赖云授权的安全骨架。建议按以下顺序继续: -1. 在百炼控制台撤销聊天中暴露的旧 Key,创建新 Key,并仅通过未提交的 Docker Secret 注入。 -2. 分别运行 `text-embedding-v4`、`qwen3-rerank`、`deepseek-v4-flash` 受控 live smoke,记录脱敏 provider profile、能力指纹、维度、流式协议和错误映射。 -3. 用现有 20 条 synthetic 数据运行真实 Embedding/Rerank/Chat 链路和幂等 seed;真实地质资料仍不得出域。 -4. 关闭 S1-B 并把 Stage 1 标记为 `DONE` 后,再实现 Stage 2 的 Worker 租约、fencing、reaper、恢复测试和完整 Compose。 +1. 在百炼控制台确认旧 Key 已撤销,并核对当前本地 Secret 对应的工作空间、北京地域、端点、计费方案和模型权限;Key 只保存在未提交 Secret 中。 +2. 通过独立 `model-gateway` 重跑三能力 smoke;当前三项 401 未消除前保持 S1-B 未完成。 +3. 三能力成功后,用现有 20 条 synthetic 数据运行真实 Embedding/Rerank/Chat 链路和幂等 seed;真实地质资料仍不得出域。 +4. 继续实现 Stage 2 的 Worker 租约、fencing、reaper、恢复测试和完整 Compose,不让外部 401 阻塞可离线验证的工程工作。 5. Stage 3 实现数字 PDF/DOCX/TXT/Markdown 的隔离解析、页码/章节恢复、审批状态机、切分、向量化写库和可追溯版本激活。 6. Stage 4 完成真实向量召回、重排与验证集评测;Stage 5 再实现 grounded chat SSE、取消传播、引用解析和聊天 UI,不把当前代理分块测试误称为聊天功能完成。 diff --git a/docs/05-stage1-runbook.md b/docs/05-stage1-runbook.md index 4ac80f4..192c3aa 100644 --- a/docs/05-stage1-runbook.md +++ b/docs/05-stage1-runbook.md @@ -14,7 +14,7 @@ make backend-sync bash scripts/init-local-secrets.sh ``` -脚本只生成三组彼此不同的随机 PostgreSQL 密码,文件权限为 `0600`。不带 `--with-bailian` 时不会创建模型 Key。 +脚本生成三组彼此不同的 PostgreSQL 密码,以及互不相同的 `model_gateway_api_token`、`model_gateway_worker_token`,文件权限为 `0600`。不带 `--with-bailian` 时不会创建或修改百炼 Key;若本地已存在 Key,脚本会明确提示保留原文件。 ## 2. 离线数据库与向量 PoC @@ -46,7 +46,7 @@ Seed 的实际流程是: ### 2.1 查看后端运行效果 -内部 API 只连接 `internal` data 网络,只挂载 app 数据库 Secret,不连接外网,也不挂载百炼 Key。无 Secret、无数据库凭证的 gateway 连接 internal ingress/data;React/Nginx Web 连接 edge/ingress,并且是唯一发布到本机回环地址 `127.0.0.1:8000` 的容器。三个运行容器均使用非 root 用户、只读根文件系统并移除 Linux capabilities。 +内部 API 只连接 internal `data + model`,挂载 app 数据库 Secret 和 API 内部 token,不连接公网,也不挂载百炼 Key。独立 `model-gateway` 只连接 internal `model + egress`,是唯一挂载百炼 Key 的服务;它不连接数据库、不挂载上传卷、不发布端口。真实 `provider-smoke` 和 `seed-demo` 也只通过内部 token 调用该服务,不直持 Key。无数据库/模型 Secret 的入口 gateway 连接 internal ingress/data;React/Nginx Web 连接 edge/ingress,并且是唯一发布到本机回环地址 `127.0.0.1:8000` 的容器。 ```bash curl http://127.0.0.1:8000/health/live @@ -82,7 +82,7 @@ bash scripts/init-local-secrets.sh --with-bailian docker compose --profile tools run --rm provider-smoke ``` -`provider-smoke` 依次验证: +`provider-smoke` 以 API 内部身份调用 `model-gateway`,再由后者依次验证: - `text-embedding-v4` 返回 1 个 1024 维有限非零向量; - `qwen3-rerank` 返回可映射到本地候选的下标和分数; @@ -97,7 +97,9 @@ docker compose --profile tools run --rm seed-demo docker compose --profile tools run --rm seed-demo ``` -真实模式仍只允许本仓库的虚构样例;任何真实地质报告必须等 Stage 3 的许可、涉密和 outbound manifest 审核链完成。 +真实模式使用 Worker 内部身份,仍只允许本仓库的虚构样例;任何真实地质报告必须等 Stage 3 的许可、涉密和 outbound manifest 审核链完成。 + +截至 2026-07-13,已通过独立 `model-gateway` 重跑三项真实调用:三项都到达供应商,但均返回安全脱敏的 `authentication` 类别(内部非流式调用由 Gateway 以 502 封装,Chat SSE 以终态错误事件返回;根因仍是供应商鉴权失败)。此时应核对 Key 所属工作空间、北京地域、专属端点、计费方案和模型权限,不要反复自动重试。在三项成功前不得声明百炼接入验收通过。 ## 4. 质量门禁与排障 @@ -113,6 +115,7 @@ docker compose logs --no-log-prefix migrate |---|---| | `invalid_local_configuration` | 检查是否仍是占位 URL、两个 URL 是否同一北京工作空间、新 Key 文件是否存在 | | migrate 等不到 DB | 检查三个数据库 Secret 是否存在且互不相同;查看 bootstrap 日志 | +| `migrate` 显示 `Exited (0)` | 正常:它是一次性 Alembic 服务,成功升级到 head 后就应退出;只有非 0 或反复重启才是故障 | | 401/403 | 不重试风暴;检查新 Key、工作空间和模型授权 | | 429/5xx/timeout | 适配器执行有界指数退避;持续失败时保留脱敏 request ID 后停止 | | seed 计数不是 20 | 不进入下一阶段;检查迁移、manifest 约束和事务日志 | @@ -127,4 +130,4 @@ docker compose down ## 5. 当前完成边界 -可在没有百炼 Key 时验收:配置/Secret 安全、MockTransport 契约、离线向量/重排、Compose 渲染、空卷迁移、20 条写入和幂等。只有轮换后的新 Key 完成三模型真实 smoke 与真实 seed 后,Stage 1 才能从 `IN_PROGRESS` 改为 `DONE`。 +可在没有可用百炼权限时验收:配置/Secret 安全、内部 token 身份、MockTransport 契约、离线向量/重排、Compose 渲染、迁移、20 条写入和幂等。当前代码还包含 FastAPI 应用工厂、稳定 Problem/trace 契约,以及 `0002_model_profiles` 的 profile/cache/assignment/invocation/citation 迁移;这些不代表上传、正式检索、grounded chat、Worker 和评测已完成。只有有效 Key 完成三模型真实 smoke 与真实 seed 后,Stage 1 才能从 `IN_PROGRESS` 改为 `DONE`。 diff --git a/docs/README.md b/docs/README.md index 43398b7..3c245a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,10 +4,12 @@ - Stage 0“仓库、安全和设计基线”已完成并推送。 - Stage 1“模型与数据库 PoC”正在进行。 -- 当前整体完成度约 12%,合理区间为 10%–15%。 -- 00–03 的设计基线已完成;业务代码完成度约 18%,Stage 1 安全离线子阶段约 95%。 -- 后端离线链路、20 条 synthetic 数据、React 离线演示、Nginx 单入口和四网络隔离均已验收;真实百炼三模型 smoke 仍待旧 Key 撤销并轮换。 -- Stage 2 仍为 `TODO`:当前只完成了不依赖云密钥的前置,Worker 租约、完整 Compose 和产品工作流尚未完成。 +- 当前整体里程碑仍约 12%,合理区间为 10%–15%;新增代码不在阶段验收前提前计入完成度。 +- 00–03 的设计基线已完成;后端离线链路、20 条 synthetic 数据、React 离线演示和 Nginx 单入口已验收。 +- 五层网络与独立 `model-gateway` 已实现:只有该服务持百炼 Key/egress,API、seed、smoke 使用区分 API/Worker 身份的内部 token。 +- FastAPI 应用工厂、稳定 Problem/trace 契约,以及 `0002_model_profiles` 的 profile/cache/assignment/invocation/citation 迁移代码已落地。 +- 真实 `text-embedding-v4`、`qwen3-rerank`、`deepseek-v4-flash` 当前仍全部返回 401,真实百炼能力尚未验收。 +- Stage 2 仍为 `TODO`:Worker 租约/fencing/reaper、上传入库、正式检索、grounded chat、评测和完整产品 UI 尚未完成。 - 后续状态、依赖、验收证据、工期和提交节点以 [项目全生命周期 TODO](04-project-todo.md) 为准。 ## 文档列表 diff --git a/docs/adr/0005-isolate-model-egress.md b/docs/adr/0005-isolate-model-egress.md new file mode 100644 index 0000000..afaf3e3 --- /dev/null +++ b/docs/adr/0005-isolate-model-egress.md @@ -0,0 +1,68 @@ +# ADR-0005:使用独立 Model Gateway 隔离百炼出口 + +- **状态:** accepted +- **日期:** 2026-07-13 + +## 背景 + +在线 RAG 必须同步完成 Query Embedding、Rerank 和 Chat SSE,但 ADR-0004 又要求数据库感知 API 不持有百炼 Key、没有公网默认出口。让 API 或 Worker 直接调用百炼,会把业务数据、数据库凭据、模型凭据和公网出口集中到同一信任边界;只保留一次性 smoke/seed 工具则无法支持在线问答。 + +## 决策 + +新增与 API 共用 backend 镜像的长期 `model-gateway` 服务: + +```text +browser + -> web (edge + ingress) + -> gateway (ingress + data) + -> api (data + model) + -> PostgreSQL/pgvector (data) + -> model-gateway (model + egress) + -> Alibaba Cloud Model Studio + +worker (data + model) + -> PostgreSQL/pgvector + -> model-gateway +``` + +- 只有 `model-gateway` 挂载 `bailian_api_key` 并连接 `egress`;它不连接 `data`、不挂数据库 Secret、上传卷或宿主端口。 +- API 与 Worker 不挂百炼 Key、不连接 `egress`,只通过 internal `model` 网络访问固定的 `http://model-gateway:8000`。 +- API 与 Worker 使用不同的内部令牌。调用必须同时携带 `Authorization: Bearer ...` 与 `X-RAG-Caller`;Gateway 用常量时间比较把令牌映射到调用者,不能只信任 header 或 Docker 来源地址。 +- API 允许 Query Embedding、Rerank、Chat 和受控 provider health;Worker 额外允许 Document Embedding。任意越权调用失败关闭。 +- Model Gateway 只接受固定 profile、固定模型和固定端点的 vendor-neutral DTO,不接受客户端提供 URL、模型名、维度或任意转发目标。 +- Embedding、Rerank 和 Chat 继续复用现有 provider ports 与百炼 adapter,不引入 SDK、Redis、消息队列、服务网格或第二套实现。 +- 日志只允许 trace、调用者、操作、profile、输入数量、usage、耗时、request ID 和脱敏错误码;禁止记录 Key、Authorization、URL、query、messages、候选正文或响应正文。 +- 容器 readiness 只验证本地配置、Secret 和客户端初始化,不产生模型费用;真实探测使用受控内部端点和显式命令。 + +## 内部接口 + +- `POST /internal/v1/embeddings`:接收 `texts` 和 `input_type=query|document`;Query 恰好一条,Document 为 1–10 条;返回 1024 维向量、resolved model、usage、request ID 和耗时。 +- `POST /internal/v1/rerank`:接收 query、候选正文和 Top N;返回原始下标、score、受校验正文与 provider 元数据。 +- `POST /internal/v1/chat/completions`:非流式内部调用,用于需要先完成引用校验的场景。 +- `POST /internal/v1/chat/stream`:vendor-neutral SSE;流开始后的 provider 错误转换为终态 `error` 事件,取消必须关闭上游流。 +- `GET /health/live` 与 `GET /health/ready`:不调用百炼。 + +公共 API 不把 Model Gateway 的内部 401/403 原样返回浏览器;这类错误统一映射为可观测的 503 配置故障。429、timeout、5xx 和非法响应分别映射为稳定、脱敏的错误码。首个输出之后不得自动重试 Chat,避免重复文本。 + +## 被否决方案 + +1. **API 直接挂 Key 与 egress:** 数据库感知进程同时拥有数据、模型凭据和公网出口。 +2. **Worker 直接调用百炼:** 会形成两个供应商调用实现和两个 Key 边界。 +3. **Model Gateway 连接数据库验证审批:** 重新形成“数据 + Key + egress”的高风险组合。 +4. **只依赖 Docker DNS 或来源 IP:** 网络成员不是应用层身份,不能替代内部令牌。 +5. **引入通用 API Gateway、Redis、Celery 或服务网格:** 当前单机毕设规模没有足够收益,并增加部署与恢复面。 + +## 后续约束 + +- `provider-smoke` 与真实 seed 必须使用 Model Gateway 客户端,不允许恢复直连百炼旁路。 +- 普通在线查询只能使用知识库已激活的 embedding profile;Fake 与 Bailian profile 永不混查。 +- Docker bridge 不是域名白名单。生产环境仍需主机防火墙或出口代理只放行获批的百炼域名。 +- 改变模型调用者、内部鉴权、网络成员、Key 边界或 SSE 协议时,必须更新本 ADR 并重跑容器网络、Secret、取消和错误脱敏验收。 + +## 验证要求 + +- Compose 合同证明百炼 Key 与 `egress` 只存在于 `model-gateway`。 +- Model Gateway 无数据库 Secret、`data` 网络、上传卷和宿主端口;API/Worker 无百炼 Key和 `egress`。 +- 缺失/错误令牌、调用者冒充、scope 越权、URL/模型/维度注入均有失败测试。 +- Embedding、Rerank、Chat 的成功、401、429、timeout、5xx、非法响应、SSE 取消均由 hermetic contract test 覆盖。 +- `make verify`、镜像 Secret 扫描、Docker 健康检查、网络隔离与真实三模型 smoke 全部通过后,才能宣称在线百炼模型边界可用。 diff --git a/docs/adr/README.md b/docs/adr/README.md index e3bd0fa..daad43f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,3 +8,4 @@ ADR 用于记录会长期影响系统的技术决策。状态使用 `proposed` - [0002-separate-bailian-protocols.md](0002-separate-bailian-protocols.md):分离百炼 Chat/Embedding 与 Rerank 协议适配器。 - [0003-text-first-scope.md](0003-text-first-scope.md):第一版采用文本优先边界,不宣称地质图空间理解。 - [0004-secretless-web-ingress.md](0004-secretless-web-ingress.md):用无 Secret 的 Nginx Web 与固定上游 gateway 隔离浏览器、API 和数据库网络。 +- [0005-isolate-model-egress.md](0005-isolate-model-egress.md):用独立 Model Gateway 隔离百炼 Key、模型出口与数据库感知服务。 diff --git a/scripts/init-local-secrets.sh b/scripts/init-local-secrets.sh index 0796bca..f0823ce 100755 --- a/scripts/init-local-secrets.sh +++ b/scripts/init-local-secrets.sh @@ -21,6 +21,8 @@ generate_secret() { generate_secret postgres_bootstrap_password generate_secret postgres_migrator_password generate_secret postgres_app_password +generate_secret model_gateway_api_token +generate_secret model_gateway_worker_token if [[ "${1:-}" == "--with-bailian" ]]; then bailian_path="$secret_dir/bailian_api_key" @@ -41,5 +43,9 @@ if [[ "${1:-}" == "--with-bailian" ]]; then unset bailian_key printf 'Created local Bailian secret without echoing its value: %s\n' "$bailian_path" else - printf 'Bailian key not created. Rotate the exposed key, then rerun with --with-bailian.\n' + if [[ -s "$secret_dir/bailian_api_key" ]]; then + printf 'Existing local Bailian secret was not modified.\n' + else + printf 'Bailian key not created. Rotate the exposed key, then rerun with --with-bailian.\n' + fi fi