Isolate cloud model access before enabling product RAG workflows
Some checks failed
verify / verify (push) Has been cancelled

The API and ingestion tools now use a fixed internal model gateway while
governed profiles, embedding cache assignments, traceable citations, and
stable API errors establish the boundaries required by later workflows.

Constraint: The current Alibaba Cloud workspace rejects all three live model calls with authentication failures
Rejected: Give the API or seed tools the Bailian key and direct egress | combines database access, cloud credentials, and public network access
Rejected: Mix offline and Bailian vectors in one demo namespace | makes profile activation and retrieval ambiguous
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep Bailian credentials and egress exclusive to model-gateway and create a new immutable profile hash for any embedding identity change
Tested: make verify; 121 backend tests; 14 frontend tests; fresh and populated Alembic upgrade-downgrade-upgrade; two idempotent offline seeds; Docker health and HTTP retrieval; isolated provider smoke
Not-tested: Successful live Bailian responses because the supplied workspace credential currently fails authentication
This commit is contained in:
2026-07-13 04:09:06 +08:00
parent 99b7df64ea
commit 75592af33a
28 changed files with 3932 additions and 254 deletions

View File

@@ -0,0 +1,223 @@
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
MIGRATION_PATH = ROOT / "backend/migrations/versions/0002_model_profiles_and_invocations.py"
MIGRATION = MIGRATION_PATH.read_text(encoding="utf-8")
NORMALIZED = " ".join(MIGRATION.lower().split())
def _table_definition(name: str) -> str:
pattern = re.compile(rf"(?ms)create table rag\.{re.escape(name)} \((.*?)^ \);")
match = pattern.search(MIGRATION.lower())
assert match is not None, f"missing table definition: rag.{name}"
return " ".join(match.group(1).split())
def test_revision_is_additive_after_initial_schema() -> None:
assert 'revision: str = "0002_model_profiles"' in MIGRATION
assert 'down_revision: str | none = "0001_initial_schema"' in MIGRATION.lower()
revision_match = re.search(r'^revision: str = "([^"]+)"$', MIGRATION, re.MULTILINE)
assert revision_match is not None
assert len(revision_match.group(1)) <= 32
assert "alter table rag.chunks" in NORMALIZED
assert "alter table rag.knowledge_bases" in NORMALIZED
assert "drop table if exists rag.chunks" not in NORMALIZED
assert "drop table if exists rag.knowledge_bases" not in NORMALIZED
def test_model_profiles_have_governed_identity_and_dimension_contract() -> None:
table = _table_definition("model_profiles")
for column in (
"profile_hash char(64) primary key",
"alias text not null",
"kind text not null",
"provider text not null",
"model text not null",
"api_mode text not null",
"dimension smallint",
"endpoint_identity_hash char(64) not null",
"config_snapshot jsonb not null default '{}'::jsonb",
"synthetic boolean not null default false",
"enabled boolean not null default true",
"created_at timestamptz not null default now()",
"updated_at timestamptz not null default now()",
):
assert column in table
assert "model_profiles_alias_key unique (alias)" in table
assert "model_profiles_hash_kind_key unique (profile_hash, kind)" in table
assert "kind in ('embedding', 'rerank', 'chat')" in table
assert "kind = 'embedding' and dimension = 1024" in table
assert "kind in ('rerank', 'chat') and dimension is null" in table
assert "profile_hash ~ '^[0-9a-f]{64}$'" in table
assert "endpoint_identity_hash ~ '^[0-9a-f]{64}$'" in table
assert "jsonb_typeof(config_snapshot) = 'object'" in table
assert "model_profiles_config_snapshot_has_no_credentials" in table
for credential_name in (
"api[_-]?key",
"secret",
"password",
"token",
"authorization",
"credential",
):
assert credential_name in table
def test_knowledge_base_active_profile_is_nullable_and_restrictive() -> None:
assert "add column active_embedding_profile_hash char(64)" in NORMALIZED
assert (
"add column active_embedding_profile_kind text not null default 'embedding'" in NORMALIZED
)
assert "knowledge_bases_active_embedding_profile_hash_format" in NORMALIZED
assert "knowledge_bases_active_embedding_profile_kind" in NORMALIZED
assert "check (active_embedding_profile_kind = 'embedding')" in NORMALIZED
assert "knowledge_bases_active_embedding_profile_fk" in NORMALIZED
assert (
"foreign key ( active_embedding_profile_hash, active_embedding_profile_kind )" in NORMALIZED
)
assert "references rag.model_profiles (profile_hash, kind) on delete restrict" in NORMALIZED
assert "active_embedding_profile_hash is null" in NORMALIZED
def test_legacy_backfill_only_activates_one_unambiguous_searchable_fake_profile() -> None:
assert "insert into rag.model_profiles" in NORMALIZED
assert "chunk.searchable is true" in NORMALIZED
assert "lower(chunk.embedding_model) like 'fake-%'" in NORMALIZED
assert "chunk.embedding_dimension = 1024" in NORMALIZED
assert "having count(distinct chunk.embedding_model) = 1" in NORMALIZED
assert "'local-synthetic'" in NORMALIZED
assert "'deterministic-offline'" in NORMALIZED
assert "sha256(convert_to('local-fake', 'utf8'))" in NORMALIZED
assert "'existing_searchable_fake_chunks'" in NORMALIZED
assert "on conflict (profile_hash) do nothing" in NORMALIZED
activation_start = NORMALIZED.index("with unique_searchable_fake_profile as")
activation_end = NORMALIZED.index(") update rag.knowledge_bases", activation_start)
candidate_query = NORMALIZED[activation_start:activation_end]
assert "group by chunk.knowledge_base_id" in candidate_query
assert "having count(distinct chunk.embedding_profile_hash) = 1" in candidate_query
assert "limit 1" not in candidate_query
assert "active_embedding_profile_hash is null" in NORMALIZED[activation_start:]
def test_embedding_cache_is_profile_and_exact_text_keyed() -> None:
table = _table_definition("embedding_cache")
assert "profile_hash char(64) not null" in table
assert "profile_kind text not null default 'embedding'" in table
assert "embedding_text_sha256 char(64) not null" in table
assert "embedding vector(1024) not null" in table
assert "resolved_model text not null" in table
assert "provider_request_id text" in table
assert "usage jsonb not null default '{}'::jsonb" in table
assert "elapsed_ms integer not null" in table
assert "primary key (profile_hash, embedding_text_sha256)" in table
assert "references rag.model_profiles (profile_hash, kind) on delete restrict" in table
assert "profile_kind = 'embedding'" in table
assert "vector_dims(embedding) = 1024" in table
assert "jsonb_typeof(usage) = 'object'" in table
def test_chunk_assignments_bind_chunk_text_profile_cache_and_state() -> None:
table = _table_definition("chunk_embedding_assignments")
assert "primary key (chunk_id, profile_hash)" in table
assert "profile_kind text not null default 'embedding'" in table
assert "foreign key (chunk_id, embedding_text_sha256)" in table
assert "references rag.chunks (id, embedding_text_sha256) on delete cascade" in table
assert "foreign key (profile_hash, profile_kind)" in table
assert "references rag.model_profiles (profile_hash, kind) on delete restrict" in table
assert "profile_kind = 'embedding'" in table
assert "foreign key (cache_profile_hash, cache_embedding_text_sha256)" in table
assert "references rag.embedding_cache (profile_hash, embedding_text_sha256)" in table
assert "status in ('pending', 'embedding', 'ready', 'failed', 'stale')" in table
assert "status = 'ready' and cache_profile_hash = profile_hash" in table
assert "cache_embedding_text_sha256 = embedding_text_sha256" in table
assert "status <> 'ready' and cache_profile_hash is null" in table
assert "chunks_id_embedding_text_sha256_key" in NORMALIZED
def test_invocation_audit_table_is_metadata_only() -> None:
table = _table_definition("model_invocations")
for field in (
"trace_id uuid not null",
"caller text not null",
"operation text not null",
"profile_hash char(64) not null",
"model text not null",
"provider_request_id text",
"status text not null",
"item_count integer not null default 0",
"prompt_tokens integer not null default 0",
"completion_tokens integer not null default 0",
"total_tokens integer not null default 0",
"elapsed_ms integer",
"error_code text",
"started_at timestamptz not null default now()",
"finished_at timestamptz",
"created_at timestamptz not null default now()",
):
assert field in table
for forbidden_field in (
"api_key",
"secret",
"authorization",
"credential",
"endpoint",
"url",
"payload",
"request_body",
"response_body",
"prompt_text",
"query_text",
"input_text",
"output_text",
"content",
):
assert forbidden_field not in table
assert "total_tokens = prompt_tokens + completion_tokens" in table
assert "foreign key (profile_hash, operation)" in table
assert "references rag.model_profiles (profile_hash, kind)" in table
assert "status = 'succeeded' and error_code is null" in table
assert "status = 'failed' and error_code is not null" in table
assert "status = 'unknown' and error_code is not null" in table
assert "status = 'started' and elapsed_ms is null" in table
assert "status = 'started' and finished_at is null" in table
def test_chunks_get_stable_citations_and_active_profile_filter_index() -> None:
assert "add column citation_id uuid not null default gen_random_uuid()" in NORMALIZED
assert "chunks_citation_id_key unique (citation_id)" in NORMALIZED
assert "create index chunks_active_embedding_profile_filter" in NORMALIZED
assert (
"on rag.chunks ( knowledge_base_id, embedding_profile_hash, access_scope_id ) "
"where searchable"
) in NORMALIZED
def test_downgrade_removes_dependents_before_profiles_and_added_columns() -> None:
invocation_drop = NORMALIZED.index("drop table if exists rag.model_invocations")
assignment_drop = NORMALIZED.index("drop table if exists rag.chunk_embedding_assignments")
cache_drop = NORMALIZED.index("drop table if exists rag.embedding_cache")
chunk_binding_drop = NORMALIZED.index(
"drop constraint if exists chunks_id_embedding_text_sha256_key"
)
knowledge_base_fk_drop = NORMALIZED.index(
"drop constraint if exists knowledge_bases_active_embedding_profile_fk"
)
profile_drop = NORMALIZED.index("drop table if exists rag.model_profiles")
assert invocation_drop < profile_drop
assert assignment_drop < cache_drop < chunk_binding_drop < profile_drop
assert knowledge_base_fk_drop < profile_drop
assert "drop column if exists citation_id" in NORMALIZED
assert "drop column if exists active_embedding_profile_hash" in NORMALIZED
assert "drop column if exists active_embedding_profile_kind" in NORMALIZED

View File

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

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import uuid
import httpx
import pytest
from fastapi import APIRouter
from app.core.problems import ApiProblem
from app.main import create_app
@pytest.mark.asyncio
async def test_application_factory_generates_openapi_without_runtime_secrets() -> None:
app = create_app()
schema = app.openapi()
assert schema["openapi"].startswith("3.")
assert "/api/v1/health/live" in schema["paths"]
assert "/api/v1/meta" in schema["paths"]
assert "/health/live" not in schema["paths"]
@pytest.mark.asyncio
async def test_trace_id_accepts_only_uuid_and_is_returned() -> None:
app = create_app()
transport = httpx.ASGITransport(app=app)
supplied = str(uuid.uuid4())
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
accepted = await client.get("/api/v1/health/live", headers={"x-request-id": supplied})
replaced = await client.get(
"/api/v1/health/live",
headers={"x-request-id": "secret-or-unbounded-client-value"},
)
assert accepted.headers["x-request-id"] == supplied
assert uuid.UUID(replaced.headers["x-request-id"])
assert replaced.headers["x-request-id"] != "secret-or-unbounded-client-value"
@pytest.mark.asyncio
async def test_formal_api_problem_is_sanitized_and_traceable() -> None:
app = create_app()
router = APIRouter()
@router.get("/api/v1/problem-test")
async def fail() -> None:
raise ApiProblem(
status=409,
code="VERSION_CONFLICT",
title="Version conflict",
detail="The resource changed; reload and retry.",
)
app.include_router(router)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/problem-test")
payload = response.json()
assert response.status_code == 409
assert response.headers["content-type"].startswith("application/problem+json")
assert payload["code"] == "VERSION_CONFLICT"
assert uuid.UUID(payload["trace_id"])
assert payload["field_errors"] == []

View File

@@ -46,6 +46,27 @@ def test_base_urls_drop_trailing_slashes() -> None:
assert settings.bailian_rerank_base_url.endswith("/v1")
def test_model_gateway_url_is_fixed_to_internal_service() -> None:
settings = Settings(model_gateway_base_url="http://model-gateway:8000/")
assert settings.model_gateway_base_url == "http://model-gateway:8000"
@pytest.mark.parametrize(
"value",
[
"https://model-gateway:8000",
"http://model-gateway:9000",
"http://127.0.0.1:8000",
"http://model-gateway:8000/proxy",
"http://user:model-token@model-gateway:8000",
],
)
def test_model_gateway_url_rejects_ssrf_and_credential_variants(value: str) -> None:
with pytest.raises(ValueError, match="fixed internal service URL"):
Settings(model_gateway_base_url=value)
def test_embedding_dimension_accepts_compose_string(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("EMBEDDING_DIMENSION", "1024")

View File

@@ -0,0 +1,748 @@
import asyncio
import json
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from pathlib import Path
from typing import cast
import httpx
import pytest
from fastapi import FastAPI
from starlette.requests import ClientDisconnect
from starlette.types import Message, Scope
from app.core.config import Settings
from app.model_gateway import create_model_gateway_app
from app.ports.model_providers import (
ChatCompletionResult,
ChatMessage,
ChatStreamEvent,
EmbeddingResult,
ModelProviderError,
ProviderErrorKind,
ProviderUsage,
RankedItem,
RerankResult,
)
API_TOKEN = "test-only-api-token" # noqa: S105
WORKER_TOKEN = "test-only-worker-token" # noqa: S105
ALLOWED_TOKENS = {"api": API_TOKEN, "worker": WORKER_TOKEN}
class _FakeEmbedding:
def __init__(self) -> None:
self.document_calls = 0
self.query_calls = 0
self.active = 0
self.max_active = 0
self.delay = 0.0
self.error: Exception | None = None
async def _result(self, texts: Sequence[str]) -> EmbeddingResult:
if self.error is not None:
raise self.error
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
if self.delay:
await asyncio.sleep(self.delay)
return EmbeddingResult(
vectors=tuple((float(index + 1), 0.5) for index, _ in enumerate(texts)),
model="fake-embedding",
request_id="req-embedding",
usage=ProviderUsage(input_tokens=len(texts), total_tokens=len(texts)),
elapsed_ms=1.25,
)
finally:
self.active -= 1
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult:
self.document_calls += 1
return await self._result(texts)
async def embed_query(self, text: str) -> EmbeddingResult:
self.query_calls += 1
return await self._result([text])
class _FakeReranker:
def __init__(self) -> None:
self.calls = 0
async def rerank(
self,
query: str,
documents: Sequence[str],
*,
top_n: int,
instruct: str | None = None,
) -> RerankResult:
del query, instruct
self.calls += 1
return RerankResult(
items=tuple(
RankedItem(index=index, relevance_score=1.0 - index / 10, document=document)
for index, document in enumerate(documents[:top_n])
),
model="fake-reranker",
request_id="req-rerank",
usage=ProviderUsage(input_tokens=len(documents), total_tokens=len(documents)),
elapsed_ms=2.5,
)
class _FakeChat:
def __init__(self) -> None:
self.complete_calls = 0
self.stream_error: ModelProviderError | None = None
self.stream_events: tuple[ChatStreamEvent, ...] | None = None
async def complete(
self,
messages: Sequence[ChatMessage],
*,
max_tokens: int,
) -> ChatCompletionResult:
del max_tokens
self.complete_calls += 1
return ChatCompletionResult(
content=f"answer:{messages[-1].content}",
finish_reason="stop",
model="fake-chat",
request_id="req-chat",
usage=ProviderUsage(input_tokens=3, output_tokens=2, total_tokens=5),
elapsed_ms=3.75,
)
async def stream(
self,
messages: Sequence[ChatMessage],
*,
max_tokens: int,
) -> AsyncIterator[ChatStreamEvent]:
del messages, max_tokens
if self.stream_error is not None:
raise self.stream_error
if self.stream_events is not None:
for event in self.stream_events:
yield event
return
yield ChatStreamEvent(
delta="第一段",
finish_reason=None,
model="fake-chat",
request_id="req-stream",
usage=ProviderUsage(),
elapsed_ms=1.0,
)
yield ChatStreamEvent(
delta="第二段",
finish_reason="stop",
model="fake-chat",
request_id="req-stream",
usage=ProviderUsage(input_tokens=3, output_tokens=2, total_tokens=5),
elapsed_ms=2.0,
)
class _TrackedChatStream:
def __init__(self) -> None:
self.closed = False
self.emitted = False
def __aiter__(self) -> AsyncIterator[ChatStreamEvent]:
return self
async def __anext__(self) -> ChatStreamEvent:
if self.emitted:
await asyncio.Event().wait()
raise StopAsyncIteration
self.emitted = True
return ChatStreamEvent(
delta="first",
finish_reason=None,
model="fake-chat",
request_id="req-stream",
usage=ProviderUsage(),
elapsed_ms=1.0,
)
async def aclose(self) -> None:
self.closed = True
class _TrackedChat(_FakeChat):
def __init__(self, stream: _TrackedChatStream) -> None:
super().__init__()
self.tracked_stream = stream
def stream(
self,
messages: Sequence[ChatMessage],
*,
max_tokens: int,
) -> AsyncIterator[ChatStreamEvent]:
del messages, max_tokens
return self.tracked_stream
class _ExplodingEmbeddingResult:
@property
def vectors(self) -> tuple[tuple[float, ...], ...]:
raise RuntimeError("private-dto-exception-text")
class _MalformedEmbedding(_FakeEmbedding):
async def embed_query(self, text: str) -> EmbeddingResult:
del text
self.query_calls += 1
return cast(EmbeddingResult, _ExplodingEmbeddingResult())
@asynccontextmanager
async def _client(
*,
embedding: _FakeEmbedding | None = None,
reranker: _FakeReranker | None = None,
chat: _FakeChat | None = None,
max_concurrency: int = 4,
) -> AsyncIterator[tuple[httpx.AsyncClient, FastAPI, _FakeEmbedding, _FakeReranker, _FakeChat]]:
embedding = embedding or _FakeEmbedding()
reranker = reranker or _FakeReranker()
chat = chat or _FakeChat()
app = create_model_gateway_app(
embedding_provider=embedding,
reranker=reranker,
chat_provider=chat,
allowed_tokens=ALLOWED_TOKENS,
max_concurrency=max_concurrency,
)
async with app.router.lifespan_context(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://model-gateway"
) as client:
yield client, app, embedding, reranker, chat
def _headers(caller: str = "api", token: str = API_TOKEN) -> dict[str, str]:
return {"Authorization": f"Bearer {token}", "X-RAG-Caller": caller}
@pytest.mark.asyncio
async def test_health_is_unauthenticated_and_schema_endpoints_are_disabled() -> None:
async with _client() as (client, app, _, __, ___):
live = await client.get("/health/live")
ready = await client.get("/health/ready")
docs = await client.get("/docs")
openapi = await client.get("/openapi.json")
assert app.docs_url is None
assert app.redoc_url is None
assert app.openapi_url is None
assert live.status_code == 200
assert ready.json() == {"status": "ready", "checks": {"configuration": "ok"}}
assert docs.status_code == 404
assert openapi.status_code == 404
@pytest.mark.asyncio
async def test_internal_authentication_binds_token_to_declared_caller() -> None:
async with _client() as (client, _, embedding, __, ___):
payload = {"texts": ["斑岩铜矿"], "input_type": "query"}
missing = await client.post("/internal/v1/embeddings", json=payload)
wrong = await client.post(
"/internal/v1/embeddings",
json=payload,
headers=_headers(token="wrong-and-sensitive-token"),
)
mismatched = await client.post(
"/internal/v1/embeddings",
json=payload,
headers=_headers(caller="worker", token=API_TOKEN),
)
assert [missing.status_code, wrong.status_code, mismatched.status_code] == [401, 401, 401]
assert missing.json()["error"]["kind"] == "unauthorized"
assert "wrong-and-sensitive-token" not in wrong.text
assert embedding.query_calls == 0
@pytest.mark.asyncio
async def test_embedding_scope_and_response_contract() -> None:
async with _client() as (client, _, embedding, __, ___):
query = await client.post(
"/internal/v1/embeddings",
json={"texts": ["斑岩铜矿"], "input_type": "query"},
headers=_headers(),
)
forbidden = await client.post(
"/internal/v1/embeddings",
json={"texts": ["内部地质报告"], "input_type": "document"},
headers=_headers(),
)
document = await client.post(
"/internal/v1/embeddings",
json={"texts": ["文档一", "文档二"], "input_type": "document"},
headers=_headers(caller="worker", token=WORKER_TOKEN),
)
assert query.status_code == 200
assert query.json() == {
"vectors": [[1.0, 0.5]],
"model": "fake-embedding",
"request_id": "req-embedding",
"usage": {"input_tokens": 1, "output_tokens": None, "total_tokens": 1},
"elapsed_ms": 1.25,
}
assert forbidden.status_code == 403
assert document.status_code == 200
assert len(document.json()["vectors"]) == 2
assert embedding.query_calls == 1
assert embedding.document_calls == 1
@pytest.mark.asyncio
async def test_validation_error_does_not_echo_rejected_content() -> None:
sensitive = "private-geological-report-fragment"
async with _client() as (client, _, embedding, __, ___):
response = await client.post(
"/internal/v1/embeddings",
json={"texts": [sensitive, "second query"], "input_type": "query"},
headers=_headers(),
)
assert response.status_code == 422
assert response.json() == {
"error": {"kind": "invalid_request", "retryable": False, "request_id": None}
}
assert sensitive not in response.text
assert embedding.query_calls == 0
@pytest.mark.asyncio
async def test_rerank_and_chat_completion_contracts() -> None:
async with _client() as (client, _, __, reranker, chat):
rerank_response = await client.post(
"/internal/v1/rerank",
json={"query": "铜矿", "documents": ["铜矿蚀变", "煤层"], "top_n": 1},
headers=_headers(),
)
chat_response = await client.post(
"/internal/v1/chat/completions",
json={"messages": [{"role": "user", "content": "结论是什么?"}], "max_tokens": 32},
headers=_headers(),
)
assert rerank_response.status_code == 200
assert rerank_response.json()["items"] == [
{"index": 0, "relevance_score": 1.0, "document": "铜矿蚀变"}
]
assert chat_response.status_code == 200
assert chat_response.json()["content"] == "answer:结论是什么?"
assert chat_response.json()["usage"]["total_tokens"] == 5
assert reranker.calls == 1
assert chat.complete_calls == 1
@pytest.mark.asyncio
async def test_provider_error_mapping_is_stable_and_redacted() -> None:
embedding = _FakeEmbedding()
embedding.error = ModelProviderError(
operation="embedding.create.private-query",
kind=ProviderErrorKind.RATE_LIMITED,
status_code=429,
provider_code="private-upstream-body",
request_id="req-safe",
retryable=True,
)
async with _client(embedding=embedding) as (client, _, __, ___, ____):
response = await client.post(
"/internal/v1/embeddings",
json={"texts": ["private-input-text"], "input_type": "query"},
headers=_headers(),
)
assert response.status_code == 429
assert response.json() == {
"error": {"kind": "rate_limited", "retryable": True, "request_id": "req-safe"}
}
assert "private" not in response.text
@pytest.mark.asyncio
async def test_unknown_provider_and_dto_failures_are_locally_sanitized(
caplog: pytest.LogCaptureFixture,
) -> None:
provider_failure = _FakeEmbedding()
provider_failure.error = RuntimeError("private-provider-exception-text")
async with _client(embedding=provider_failure) as (client, _, __, ___, ____):
provider_response = await client.post(
"/internal/v1/embeddings",
json={"texts": ["private-provider-input"], "input_type": "query"},
headers=_headers(),
)
malformed = _MalformedEmbedding()
async with _client(embedding=malformed) as (client, _, __, ___, ____):
dto_response = await client.post(
"/internal/v1/embeddings",
json={"texts": ["private-dto-input"], "input_type": "query"},
headers=_headers(),
)
safe_error = {"error": {"kind": "invalid_response", "retryable": False, "request_id": None}}
assert provider_response.status_code == 502
assert provider_response.json() == safe_error
assert dto_response.status_code == 502
assert dto_response.json() == safe_error
assert "private-provider" not in provider_response.text
assert "private-dto" not in dto_response.text
assert "private-provider-exception-text" not in caplog.text
assert "private-dto-exception-text" not in caplog.text
@pytest.mark.asyncio
async def test_sse_emits_safe_delta_complete_and_error_events() -> None:
chat = _FakeChat()
async with _client(chat=chat) as (client, _, __, ___, ____):
success = await client.post(
"/internal/v1/chat/stream",
json={"messages": [{"role": "user", "content": "回答"}], "max_tokens": 32},
headers=_headers(),
)
assert success.status_code == 200
assert success.headers["content-type"].startswith("text/event-stream")
assert success.text.count("event: delta") == 2
assert "event: complete" in success.text
assert '"total_tokens":5' in success.text
chat.stream_error = ModelProviderError(
operation="chat.stream.private",
kind=ProviderErrorKind.AUTHENTICATION,
provider_code="private-secret-body",
request_id="req-stream-safe",
)
async with _client(chat=chat) as (client, _, __, ___, ____):
failure = await client.post(
"/internal/v1/chat/stream",
json={"messages": [{"role": "user", "content": "private-question"}]},
headers=_headers(),
)
assert failure.status_code == 200
assert "event: error" in failure.text
assert '"kind":"authentication"' in failure.text
assert "private" not in failure.text
@pytest.mark.asyncio
async def test_sse_aggregates_terminal_and_later_usage_only_event() -> None:
chat = _FakeChat()
chat.stream_events = (
ChatStreamEvent(
delta="答案",
finish_reason=None,
model="fake-chat-first",
request_id="req-first",
usage=ProviderUsage(input_tokens=3),
elapsed_ms=4.0,
),
ChatStreamEvent(
delta="",
finish_reason="stop",
model="",
request_id=None,
usage=ProviderUsage(),
elapsed_ms=2.0,
),
ChatStreamEvent(
delta="",
finish_reason=None,
model="fake-chat-final",
request_id="req-final",
usage=ProviderUsage(output_tokens=2, total_tokens=5),
elapsed_ms=7.0,
),
)
async with _client(chat=chat) as (client, _, __, ___, ____):
response = await client.post(
"/internal/v1/chat/stream",
json={"messages": [{"role": "user", "content": "回答"}]},
headers=_headers(),
)
complete_block = next(
block for block in response.text.split("\n\n") if block.startswith("event: complete")
)
complete_payload = json.loads(complete_block.split("data: ", maxsplit=1)[1])
assert complete_payload == {
"elapsed_ms": 7.0,
"finish_reason": "stop",
"model": "fake-chat-final",
"request_id": "req-final",
"usage": {"input_tokens": 3, "output_tokens": 2, "total_tokens": 5},
}
@pytest.mark.asyncio
async def test_sse_without_legal_terminal_finish_emits_error_not_complete() -> None:
chat = _FakeChat()
chat.stream_events = (
ChatStreamEvent(
delta="未完成答案",
finish_reason=None,
model="fake-chat",
request_id="req-incomplete",
usage=ProviderUsage(),
elapsed_ms=1.0,
),
ChatStreamEvent(
delta="",
finish_reason=None,
model="fake-chat",
request_id="req-incomplete",
usage=ProviderUsage(total_tokens=5),
elapsed_ms=2.0,
),
)
async with _client(chat=chat) as (client, _, __, ___, ____):
response = await client.post(
"/internal/v1/chat/stream",
json={"messages": [{"role": "user", "content": "回答"}]},
headers=_headers(),
)
assert "event: complete" not in response.text
assert "event: error" in response.text
assert '"kind":"invalid_response"' in response.text
@pytest.mark.asyncio
async def test_concurrency_is_bounded_across_requests() -> None:
embedding = _FakeEmbedding()
embedding.delay = 0.02
async with _client(embedding=embedding, max_concurrency=1) as (client, _, __, ___, ____):
responses = await asyncio.gather(
client.post(
"/internal/v1/embeddings",
json={"texts": ["query one"], "input_type": "query"},
headers=_headers(),
),
client.post(
"/internal/v1/embeddings",
json={"texts": ["query two"], "input_type": "query"},
headers=_headers(),
),
)
assert [response.status_code for response in responses] == [200, 200]
assert embedding.max_active == 1
@pytest.mark.asyncio
async def test_stream_is_closed_when_downstream_disconnects() -> None:
tracked_stream = _TrackedChatStream()
chat = _TrackedChat(tracked_stream)
embedding = _FakeEmbedding()
reranker = _FakeReranker()
app = create_model_gateway_app(
embedding_provider=embedding,
reranker=reranker,
chat_provider=chat,
allowed_tokens=ALLOWED_TOKENS,
)
body = json.dumps(
{"messages": [{"role": "user", "content": "stream"}], "max_tokens": 8}
).encode()
request_delivered = False
async def receive() -> Message:
nonlocal request_delivered
if not request_delivered:
request_delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return {"type": "http.disconnect"}
async def disconnect_on_first_body(message: Message) -> None:
if message["type"] == "http.response.body" and message.get("body"):
raise OSError("downstream disconnected")
scope = cast(
Scope,
{
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.4"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/internal/v1/chat/stream",
"raw_path": b"/internal/v1/chat/stream",
"query_string": b"",
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
(b"authorization", f"Bearer {API_TOKEN}".encode()),
(b"x-rag-caller", b"api"),
],
"client": ("127.0.0.1", 12345),
"server": ("model-gateway", 8000),
},
)
async with app.router.lifespan_context(app):
with pytest.raises(ClientDisconnect):
await app(scope, receive, disconnect_on_first_body)
assert tracked_stream.emitted is True
assert tracked_stream.closed is True
@pytest.mark.asyncio
async def test_production_readiness_reads_local_secrets_without_cloud_call(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
api_key = tmp_path / "bailian"
api_token = tmp_path / "api-token"
worker_token = tmp_path / "worker-token"
api_key.write_text("test-only-bailian-key", encoding="utf-8")
api_token.write_text(API_TOKEN, encoding="utf-8")
worker_token.write_text(WORKER_TOKEN, encoding="utf-8")
monkeypatch.setenv(
"MODEL_GATEWAY_ALLOWED_TOKEN_FILES",
f"api={api_token},worker={worker_token}",
)
settings = Settings(
dashscope_api_key_file=api_key,
bailian_openai_base_url=(
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
),
bailian_rerank_base_url=(
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
),
)
app = create_model_gateway_app(settings_factory=lambda: settings)
async with app.router.lifespan_context(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://model-gateway"
) as client:
response = await client.get("/health/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready", "checks": {"configuration": "ok"}}
@pytest.mark.asyncio
async def test_rotated_internal_token_fuses_old_process_until_coordinated_restart(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
api_key = tmp_path / "bailian"
api_token = tmp_path / "api-token"
worker_token = tmp_path / "worker-token"
api_key.write_text("test-only-bailian-key", encoding="utf-8")
api_token.write_text(API_TOKEN, encoding="utf-8")
worker_token.write_text(WORKER_TOKEN, encoding="utf-8")
monkeypatch.setenv(
"MODEL_GATEWAY_ALLOWED_TOKEN_FILES",
f"api={api_token},worker={worker_token}",
)
settings = Settings(
dashscope_api_key_file=api_key,
bailian_openai_base_url=(
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
),
bailian_rerank_base_url=(
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
),
)
app = create_model_gateway_app(settings_factory=lambda: settings)
async with app.router.lifespan_context(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://model-gateway"
) as client:
assert (await client.get("/health/ready")).status_code == 200
rotated_token = "test-only-rotated-api-token"
api_token.write_text(rotated_token, encoding="utf-8")
old_credential = await client.post(
"/internal/v1/embeddings",
json={"texts": ["query"], "input_type": "query"},
headers=_headers(),
)
new_credential = await client.post(
"/internal/v1/embeddings",
json={"texts": ["query"], "input_type": "query"},
headers=_headers(token=rotated_token),
)
unhealthy = await client.get("/health/ready")
expected_restart = {
"error": {"kind": "restart_required", "retryable": False, "request_id": None}
}
assert old_credential.status_code == 503
assert old_credential.json() == expected_restart
assert new_credential.status_code == 503
assert new_credential.json() == expected_restart
assert unhealthy.status_code == 503
assert unhealthy.json()["checks"] == {"configuration": "restart_required"}
replacement = create_model_gateway_app(settings_factory=lambda: settings)
async with replacement.router.lifespan_context(replacement):
transport = httpx.ASGITransport(app=replacement)
async with httpx.AsyncClient(
transport=transport, base_url="http://replacement-model-gateway"
) as client:
recovered = await client.get("/health/ready")
assert recovered.status_code == 200
@pytest.mark.asyncio
async def test_rotated_bailian_key_is_detected_before_any_business_call(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
api_key = tmp_path / "bailian"
api_token = tmp_path / "api-token"
worker_token = tmp_path / "worker-token"
api_key.write_text("test-only-bailian-key", encoding="utf-8")
api_token.write_text(API_TOKEN, encoding="utf-8")
worker_token.write_text(WORKER_TOKEN, encoding="utf-8")
monkeypatch.setenv(
"MODEL_GATEWAY_ALLOWED_TOKEN_FILES",
f"api={api_token},worker={worker_token}",
)
settings = Settings(
dashscope_api_key_file=api_key,
bailian_openai_base_url=(
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
),
bailian_rerank_base_url=(
"https://workspace-test.cn-beijing.maas.aliyuncs.com/compatible-api/v1"
),
)
app = create_model_gateway_app(settings_factory=lambda: settings)
async with app.router.lifespan_context(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://model-gateway"
) as client:
assert (await client.get("/health/ready")).status_code == 200
api_key.write_text("test-only-rotated-bailian-key", encoding="utf-8")
response = await client.post(
"/internal/v1/embeddings",
json={"texts": ["must-not-reach-cloud"], "input_type": "query"},
headers=_headers(),
)
assert response.status_code == 503
assert response.json()["error"]["kind"] == "restart_required"

View File

@@ -0,0 +1,275 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator
import httpx
import pytest
from app.adapters.model_gateway import ModelGatewayAdapter
from app.ports.model_providers import ChatMessage, ModelProviderError, ProviderErrorKind
def _usage() -> dict[str, int]:
return {"input_tokens": 4, "output_tokens": 0, "total_tokens": 4}
def _vector() -> list[float]:
return [1.0] + [0.0] * 1023
@pytest.mark.asyncio
async def test_query_embedding_uses_fixed_origin_auth_and_query_type() -> None:
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return httpx.Response(
200,
json={
"vectors": [_vector()],
"model": "text-embedding-v4",
"request_id": "req-1",
"usage": _usage(),
"elapsed_ms": 8.5,
},
)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
try:
result = await adapter.embed_query("斑岩铜矿")
finally:
await client.aclose()
assert len(result.vectors[0]) == 1024
assert seen[0].url == "http://model-gateway:8000/internal/v1/embeddings"
assert seen[0].headers["authorization"] == "Bearer internal-token"
assert seen[0].headers["x-rag-caller"] == "api"
assert json.loads(seen[0].content) == {"texts": ["斑岩铜矿"], "input_type": "query"}
@pytest.mark.asyncio
async def test_document_embedding_identifies_worker_caller() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["x-rag-caller"] == "worker"
assert json.loads(request.content)["input_type"] == "document"
return httpx.Response(
200,
json={
"vectors": [_vector()],
"model": "text-embedding-v4",
"request_id": None,
"usage": _usage(),
"elapsed_ms": 1,
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = ModelGatewayAdapter(token="worker-token", caller="worker", http_client=client)
await adapter.embed_documents(["合成文档"])
@pytest.mark.asyncio
async def test_gateway_http_error_does_not_leak_response_or_token() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"detail": "internal-token private upstream body"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
with pytest.raises(ModelProviderError) as caught:
await adapter.embed_query("secret query")
assert caught.value.kind is ProviderErrorKind.AUTHENTICATION
assert "internal-token" not in str(caught.value)
assert "secret query" not in str(caught.value)
@pytest.mark.asyncio
async def test_gateway_preserves_only_fixed_provider_error_category() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
502,
json={
"error": {
"kind": "authentication",
"retryable": False,
"request_id": "req-safe",
"untrusted_extra": "must-not-cross-boundary",
}
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
with pytest.raises(ModelProviderError) as caught:
await adapter.embed_query("secret query")
assert caught.value.kind is ProviderErrorKind.AUTHENTICATION
assert caught.value.status_code == 502
assert caught.value.request_id == "req-safe"
assert "untrusted_extra" not in str(caught.value)
@pytest.mark.asyncio
async def test_rerank_rejects_document_mismatch_from_gateway() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"items": [{"index": 0, "relevance_score": 0.9, "document": "tampered"}],
"model": "qwen3-rerank",
"request_id": "req-2",
"usage": _usage(),
"elapsed_ms": 3,
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
with pytest.raises(ModelProviderError) as caught:
await adapter.rerank("铜矿", ["候选"], top_n=1)
assert caught.value.kind is ProviderErrorKind.INVALID_RESPONSE
@pytest.mark.asyncio
async def test_chat_completion_maps_gateway_response() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"content": "仅依据证据回答。[S1]",
"finish_reason": "stop",
"model": "deepseek-v4-flash",
"request_id": "req-chat",
"usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8},
"elapsed_ms": 9,
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
result = await adapter.complete([ChatMessage(role="user", content="问题")], max_tokens=20)
assert result.content.endswith("[S1]")
assert result.usage.total_tokens == 8
class _SseStream(httpx.AsyncByteStream):
async def __aiter__(self) -> AsyncIterator[bytes]:
yield (
b"event: delta\n"
b'data: {"delta":"answer","model":"deepseek-v4-flash",'
b'"request_id":"req"}\n\n'
)
yield (
b"event: complete\n"
b'data: {"delta":"","finish_reason":"stop",'
b'"model":"deepseek-v4-flash","request_id":"req",'
b'"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3},'
b'"elapsed_ms":5}\n\n'
)
class _IncompleteSseStream(httpx.AsyncByteStream):
async def __aiter__(self) -> AsyncIterator[bytes]:
yield (
b"event: delta\n"
b'data: {"delta":"partial","model":"deepseek-v4-flash",'
b'"request_id":"req"}\n\n'
)
class _EventAfterCompleteStream(httpx.AsyncByteStream):
async def __aiter__(self) -> AsyncIterator[bytes]:
yield (
b"event: complete\n"
b'data: {"finish_reason":"stop","model":"deepseek-v4-flash",'
b'"request_id":"req","usage":{"input_tokens":1,"output_tokens":1,'
b'"total_tokens":2},"elapsed_ms":5}\n\n'
)
yield (
b"event: delta\n"
b'data: {"delta":"late","model":"deepseek-v4-flash",'
b'"request_id":"req"}\n\n'
)
@pytest.mark.asyncio
async def test_chat_stream_parses_typed_events() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
stream=_SseStream(),
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
events = [
event
async for event in adapter.stream(
[ChatMessage(role="user", content="question")], max_tokens=20
)
]
assert [event.delta for event in events] == ["answer", ""]
assert events[-1].finish_reason == "stop"
assert events[-1].usage.total_tokens == 3
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [_IncompleteSseStream(), _EventAfterCompleteStream()])
async def test_chat_stream_requires_exactly_one_terminal_complete(
stream: httpx.AsyncByteStream,
) -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
stream=stream,
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
with pytest.raises(ModelProviderError) as caught:
_ = [
event
async for event in adapter.stream(
[ChatMessage(role="user", content="question")], max_tokens=20
)
]
assert caught.value.kind is ProviderErrorKind.INVALID_RESPONSE
@pytest.mark.asyncio
async def test_chat_stream_rejects_nonfinite_terminal_metadata() -> None:
class InvalidTerminal(httpx.AsyncByteStream):
async def __aiter__(self) -> AsyncIterator[bytes]:
yield (
b"event: complete\n"
b'data: {"finish_reason":"stop","model":"deepseek-v4-flash",'
b'"request_id":"req","usage":{},"elapsed_ms":-1}\n\n'
)
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(200, stream=InvalidTerminal())
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
adapter = ModelGatewayAdapter(token="internal-token", caller="api", http_client=client)
with pytest.raises(ModelProviderError) as caught:
_ = [
event
async for event in adapter.stream(
[ChatMessage(role="user", content="question")], max_tokens=20
)
]
assert caught.value.kind is ProviderErrorKind.INVALID_RESPONSE
def test_client_rejects_any_nonfixed_gateway_origin() -> None:
with pytest.raises(ModelProviderError):
ModelGatewayAdapter(token="token", caller="api", base_url="http://127.0.0.1:8000")

View File

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

View File

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