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