Isolate cloud model access before enabling product RAG workflows
Some checks failed
verify / verify (push) Has been cancelled
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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
65
backend/tests/unit/test_application_contract.py
Normal file
65
backend/tests/unit/test_application_contract.py
Normal 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"] == []
|
||||
@@ -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")
|
||||
|
||||
|
||||
748
backend/tests/unit/test_model_gateway.py
Normal file
748
backend/tests/unit/test_model_gateway.py
Normal 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"
|
||||
275
backend/tests/unit/test_model_gateway_client.py
Normal file
275
backend/tests/unit/test_model_gateway_client.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user